Compare commits

...
8 Commits
Author SHA1 Message Date
shenlei 1db9e9a129 no message 2026-09-16 22:15:52 +09:00
shenleiandClaude Opus 5 400e3e8ea5 Merge classic enter-game framing fixes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 21:29:22 +09:00
shenleiandClaude Opus 5 85ce75e707 fix(net): 修复 40250 进入游戏时的收包错位
- GC_ITEM_DEL(20) 按 40250 旧结构为 42 字节,GC_REFINE_INFORMATION(95) 为 59 字节
- 公会标记连接接受 GC_MARK_DIFF_DATA(101),按 1 字节消费
- GUILD_SUBHEADER_GC_SKILL_INFO 服务器声明 22 字节但实际只写 21 字节,
  按实际长度分帧,修复 "unknown GC header 200 (last: 75,20)" 断线
- 会话测试的 quest info 包改为 40250 按 flag 定长的格式,新增对应分帧测试

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 21:29:21 +09:00
shenlei 474a709f69 Merge combat 40250 parity fixes 2026-09-12 13:15:28 +09:00
shenleiandClaude Opus 5 1933a5ceda fix(combat): 战斗逐项对齐 40250,修复移动 / 技能释放与武器挂点
- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级)
- 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植)
- _register_hit 改为 map::insert 语义,修正命中上限
- 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段
- 去掉本地连击超时,motion_bound 清命中表 / 连击段号
- __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、
  命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd)
- ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING
- mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上
- 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试;
  gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体
- 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 13:15:28 +09:00
shenandshen 96c29d2bb7 Merge playable STB-01 validation fixes 2026-09-11 21:23:52 +08:00
shenandshen cc5573ddc1 fix(playable): close release validation lifecycle gaps 2026-09-11 21:23:44 +08:00
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-11 22:06:48 +09:00
105 changed files with 12458 additions and 1209 deletions
+10
View File
@@ -37,6 +37,16 @@ compile_commands.json
*.a
.env.live-smoke.local
# playable network tests: local scenario/fixture configs, credential files and
# run outputs never enter git. Only scenario.example.json and the static gate
# fixtures in test/playable are committed. Run outputs live under /build/playable.
/test/playable/*.local.json
/test/playable/*.local.txt
/test/playable/runs/
*.credentials
*.credentials.json
.env.*.local
# clangd
/.cache/
+150 -7
View File
@@ -355,8 +355,10 @@
普攻 `_swing_skill=0`),被击退目标记 `_victim_flush``_emit_swing()` 改为**只有拿不到
命中窗**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK``_flush_victim_list()`(对齐
`PythonPlayerEventHandler.cpp:194`)帧末把 `_victim_flush`(≤16)打包 `client.sync_positions()`
几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`
`(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。
~~几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`
`(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。~~
**增量 130 已替换**为 `.msa` 刀尖扫掠 Z 圆柱 vs `.msm` 防御球的 `detect_z_cylinder` 判定(见 §3.5 修改 4 /
C.5 增量 130)。
`combat_fx_test.gd``FakeNetWorld` + 6 段断言(`CG_ATTACK` 延后 / 窗内命中 / `CG_SYNC_POSITION` /
COMBO 单次 / 正面弧 / 越窗收起)。C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 84:武器种类 →
`combo_motion_mode` 映射。
@@ -700,7 +702,7 @@
新增 `_attack_ctx()``client.get_duel()` / `get_pvp_relations()` / `get_guild_wars()` /
`get_party()` 装配 ctxobserver / main_vid / pk_mode / duel_mode / duel_opponents /
pvp_pairs / gvg_pairs / party_vids)。5 处调用点(含 `__ReserveProcess_ClickActor` 第 3 步、
`_can_shot``_hit_geometry`)无签名变化。
`_can_shot``_hit_geometry`;增量 130 起后者并入 `_normal_attack_process`)无签名变化。
④ 自检 `project/entity_rules_test.gd`(第 15 个 canonical25 组断言);`combat_fx_test` /
`netplay_test` / `fly_test` 的怪物夹具补 `ch_type: 2`(此前无 `ch_type``_entity_kind` 归 PC
桩按 PC 也「可打」;1:1 规则下和平同帝国 PC 不可打 → 夹具需明确怪类别,与真实服务器一致)。
@@ -2352,9 +2354,23 @@ OnHit(uSkill, victimActor, isSendPacket):
**只有拿不到命中窗数据**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`,有窗时交给
`_attack_process``_flush_victim_list()`(对齐 `PythonPlayerEventHandler.cpp:194`)在
`_process` 帧末把 `_victim_flush`(≤ `SYNC_POSITION_LIMIT` 16)打包 `client.sync_positions()`
几何判定是 **近似**`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面
~~几何判定是 **近似**`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面
`±60°``(reach + 20 + 45) cm` 弧内;真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending
sphere 数据(同 §3.1 actor 碰撞一起留桩)。`combat_fx_test.gd``FakeNetWorld` +
sphere 数据(同 §3.1 actor 碰撞一起留桩)。~~
**增量 130 升级为逐行判定**:删除 `_hit_deduped` / `_hit_geometry`,新增 `_normal_attack_process()`
对齐 `__NormalAttackProcess`
- 距离门 `v3Distance = (dX, dZ, dZ)`:出货代码平面 Y 不参与、高度差算两次,原样保留;上限 300 cm,
`IS_HUGE_RACE` 为 500 cm。
- 命中窗 `[start,end]``[t-dt, t]` 相交后查 `m_HitDataMap`COMBO 同窗不再判,其余等 `fInvisibleTime`
- 窗内每个采样的刀尖 `last_pos → pos` 按攻击者朝向转到世界 cm,作为半径 20 的动态 Z 圆柱,
对受击方每个防御球做 `HitCollision.detect_z_cylinder()`(逐行移植
`DetectCollisionDynamicZCylinderVSDynamicZCylinder` + `IntersectLineSegments`)。
- 碰上后 `_register_hit()``_process_attack_success()`
- `_register_hit` 修正为 `map::insert` 语义:已有目标不覆盖冷却,插入后超上限才返回 FALSE。
- 防御球取 `.msm``CollisionType 3``HitCollision.parse_msm_defending`):PC 读
`root/msm/<class>_<m|w>.msm`,怪物读模型目录 `.msm`。上一帧球心缓存在 `_def_last`
- 判定时间改为 `_attacking_elapsed() = (_local_time - _motion_start_t) * _atk_speed_factor`
`GetAttackingElapsedTime`);`_local_time``_advance_local_time()` 先扣 `m_fDelay` 再推进。`combat_fx_test.gd``FakeNetWorld` +
命中窗几何判定 / `CG_ATTACK` 延后 / `CG_SYNC_POSITION` / COMBO 单次 / 正面弧 / 越窗收起 6 段断言。
5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`
`CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。**[已在增量 78 `_do_attack_swing` 落地]**
@@ -2395,8 +2411,10 @@ OnHit(uSkill, victimActor, isSendPacket):
修改 6 完成(增量 84`motion_mode_for` + `_refresh_motion_mode` 按装备武器 subtype / 骑乘 / 变身
`combo_motion_mode`,对齐 `RefreshState`);修改 7 完成(增量 85`_clickable_distance()` 弓箭
`__GetBowRange()` 分支 + `_bow_range_cm()` + `POINT_BOW_DISTANCE` 加成)。C++ 21/21 + 7 GDScript
全绿。仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、
服务端合法性校准。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。
全绿。~~仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、
服务端合法性校准。~~ —— 增量 130 已落地:Z 圆柱 vs `.msm` 防御球,以及 `__ProcessDataAttackSuccess`
的完整效果(见修改 4 / §3.7 / C.5 增量 130)。仍待:服务端合法性校准;防御球当前是模型本地近似,
未跟骨骼矩阵走。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。
---
@@ -2512,6 +2530,35 @@ handler 由 `__OnPressActor` 通过
`netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test`
全绿。
**增量 130(受击方反应 1:12026-09-12**
- 新增 `project/ui/hit_reaction.gd`,由 PlayerView / MobView 共用。`good()` / `greate()` / `stone()`
逐分支对齐 `__HitGood` / `__HitGreate` / `__HitStone`
- 击倒中不再换受击动作,Greate 在起身中也不换。
- 晕眩时:Good → `Die`(死亡动作由服务端驱动,seam);Greate 只播 `DAMAGE_FLYING(_BACK)`
并置 `m_isRealDead`,不起身。
- `isLock`(普攻 / 连击 / 技能 / 表情 / 钓鱼等)只抖动不换动作;Greate 在 `IsUsingSkill` 时也只抖。
- `scalar = dot(攻击方朝向, 受击方朝向)`:小于 0 走正面 `DAMAGE` / `DAMAGE_FLYING → STAND_UP`
否则先试 `_BACK` 版,缺资源退回正面版。
- 动作链 = `InterceptOnceMotion` + `PushOnceMotion` + 链尾 `PushLoopMotion`,回到受击前的
wait / walk / run。
- `__Shake(100)`100 ms 内模型位置每帧偏移 ±`rand()%10` cm,结束复位。
- `InsertDelay(fStiffenTime)``time_scale = 0` 冻结,到期恢复该动作的 `fSpeedRatio`;冻结期间新绑定的
动作同样保持冻结。
- PC 受击文件按 `playersettingmodule` 注册表取,同名多份等权随机:
- `damage` / `damage_1`BACK 版 `damage_2` / `damage_3`
- `damage_flying` / `back_damage_flying`
- `falling_stand` / `back_falling_stand`
- 怪物在 `STATE_MOTIONS` 新增 `BACK_DAMAGE` / `FRONT_KNOCKDOWN` / `BACK_KNOCKDOWN` / `FRONT_STANDUP` /
`BACK_STANDUP`
- `is_in_hit_reaction()` 改为返回 `_hit.active`:整条链播完才清 `net_play._knock_down`。原来的
`_state == "damage"` 分支并入链尾处理。
- 击退:`net_world.push_victim()``physics_push.gd`(逐行移植 `CPhysicsObject` +
`CEaseOutInterpolation`),每帧叠加位移。
- `blending_position()` = `GetBlendingPosition`,供 `OnHit``CG_SYNC_POSITION` 使用。
- 推动期间服务端位置变化:以新位置为基准继续叠加。
- 推动结束后服务端位置一变,即丢弃偏移。
---
### 3.8 技能缺少统一合法性检查
@@ -10145,3 +10192,99 @@ dx² + dy² > s_fLimitDistance → 不播
seam ⑨ 名字与气泡合并为单尾标链表;完整 Quest / fishing / dungeon / mount / observer 玩法与
复杂 UI 分支;真实服务器、真机、纯 pack、双架构和正式签名验收。
```
### C.5 批次记录 · 增量 130W2 / §3.5 · §3.7 战斗与 40250 对齐:命中判定、命中效果、受击反应、攻速、连击类型、技能包)
```text
批次:增量 130 —— 逐项修正与 40250 参考端战斗实现的差异(2026-09-12)。每项先写失败断言再改实现。
1. 连击类型:
net_play.gd 监听 client.combo_changed,由 _on_combo_changed() 处理,对齐
CPythonPlayer::SetComboSkillFlag
· 查连击技能 122 的等级;缺槽或等级 <= 0 直接返回,不改 combo type。
· 否则 _combo_type = enabled ? MIN(level, 2) : 0。
· 不在本地伪造技能等级。
2. 命中判定:
删除 _hit_deduped / _hit_geometryreach + 正面 ±60° 近似)。
· 新增 project/hit_collision.gd,逐行移植:
IntersectLineSegments / FindNearestPointOnLineSegment / FindNearestPointOfParallelLineSegments /
AdjustNearestPoints / DetectCollisionDynamicZCylinderVSDynamicZCylinder
另含 .msm CollisionType 3 防御球解析与 IS_HUGE_RACE。
· net_play._normal_attack_process() 流程:
(dX, dZ, dZ) 距离门 300 / 500 cm → 命中窗相交 → m_HitDataMap →
刀尖采样半径 20 的 Z 圆柱 vs 受击方防御球(上一帧球心存 _def_last)。
· PlayerView 取 root/msm/<class>_<m|w>.msmMobView 取模型目录 .msm,都经 get_defending_spheres() 提供。
· 没有 .msm 的占位节点退化为单球 HIT_FALLBACK_SPHEREseam)。
3. _register_hit
改为 map::insert 语义:已登记的目标不刷新冷却,插入后超上限才返回 FALSE。
4. 攻速:
_atk_speed_factorbAttackSpeed/100>1100 → 0)同时用于两处:
· _attacking_elapsed() = (_local_time - _motion_start_t) * fSpeedRatio
· PlayerView.play_attack_motion(mode, index, speed_ratio) 的 time_scale
play_attack_motion 按 MOTION_MODE 目录绑定 attack(_1) / combo_0N,模式目录缺该段时回退 general。
5. 连击清零:
删除本地连击超时清空。PlayerView / MobView 新增 motion_bound(state) 信号(__SetMotion 尾),
由 net_play._on_motion_bound() 处理:
· 新动作带 AttackingData → 清 m_HitDataMap。
· 连击中换到无 ComboInputData 的动作 → _combo_index = 0。
6. __ProcessDataAttackSuccess_process_attack_success):
· InsertDelay(fStiffenTime):攻击者写 _delay_advance_local_time 先扣),视图 time_scale 冻结;
受击方同样 insert_delay,并记 m_fInvisibleTime_victim_invisible_until,期间 AttackingProcess 跳过它)。
· 击退门 __CanPushDestActor_can_push):
建筑 / 门 / 石头 / NPC / 巨型不推;晕眩必推;否则要求 owner 为主角且 owner 时间 <= 3 s。
通过后 net_world.push_victim() 推动(physics_push.gd 逐行移植 CPhysicsObject + CEaseOutInterpolation)。
OnHit 时目标仍在推动中 → GetBlendingPosition 进 CG_SYNC_POSITION 列表。
· 命中特效 blow_1_low.mse
建筑 / 门放在攻击者身前 30 cm、不转向;巨型放在命中点;其余放在受击方。
game_scene 装配 net_play.fx = fx、fx_parent = Entities。
· 受击反应:石头 / 门 → hit_stoneHIT_TYPE_GOOD → hit_goodHIT_TYPE_GREAT → hit_greate
scalar = cos(攻击方 yaw 受击方 yaw)。状态机 ui/hit_reaction.gd 见 §3.7 增量 130。
· 最后 _on_hitSetTarget + CG_ATTACK。
7. 技能包:
ClassicSession::send_use_skill 删除夹带的 CG_FLY_TARGETING,只发 TPacketCGUseSkill(对齐
SendUseSkillPacket)。飞行目标仍由施法方的 CG_ADD_FLY_TARGETING / OnSetFlyTarget 发送。
net_classic_session_test 新增 "use skill wire: no extra fly targeting" 断言。
8. 既有测试随行为调整:
受击 damage 按 playersettingmodule 注册为 damage / damage_1 等权随机。
· gpu_pose_bounds_testCPU / GPU 两个视图绑定前用同一 seed,保证选中同一段。
· race_motion_assembly_testdamage 允许任一注册变体。
这两个测试在本批次改动后曾失败(CPU / GPU 选到不同段、断言写死 damage.msa),调整后通过。
回归(godot --headless --path project --script <test>.gd):
新增 PASShit_collision_test、physics_push_test、hit_view_test、net_world_push_test。
PASScombat_fx_test、gamescene_test、netplay_test、skill_test、player_move_test、input_key_test、
weapon_attach_test、mob_view_test、player_motion_test、fly_test、remote_player_test、
net_world_vis_test、forest_mob_render_test、p2b_test、playable_harness_test、playable_combat_test、
gpu_pose_bounds_test、race_motion_assembly_test。
已知失败(与本批次无关):equip_model_test "armor 11000 -> values[3]=0 回退 =vnum"(夹具期望过期)。
forest_map_render_test 是 extends Node 的 MT_TEST_MODE 入口,不能用 --script 直跑,本批次未运行。
ctest --test-dir build22/23 PASS。net.classic_session 仍是既有 25 条失败
quest / duel / messenger / exchange / guild / safebox / mall / sync / ground item / despawn),
都与 use_skill 无关;新增的 use skill 断言通过。
验收边界:
已验证(离线):Z 圆柱几何、.msm 防御球解析、命中表 / 上限、攻速缩放、连击类型与清零、
硬直 / 击退 / 同步位置 / 特效挂载、受击动作链 / 抖动 / 冻结、技能包线格式。
未验证:真实服务器下的命中与击退手感、真机视觉。
seam
· 击退没有地形 / 角色碰撞,也无 AdjustCollisionWithOtherObjects、溅射、IsResistFallen。
· owner 时间取客户端观测时刻。
· 防御球按模型本地坐标、不跟骨骼矩阵;占位节点退化为单球。
· 远端角色不跑 AttackProcess;晕眩 → Die 不在本地播。
· 攻击 / 受击动作变体等权随机;受击动作固定取 general 目录。
· 抖动用模型位置偏移,InsertDelay 用 time_scale 冻结。
· 受击链期间的循环动作请求延后到链尾。
· 推动结束后服务端位置一变即丢弃偏移。
· 本地 GC 伤害包仍直接走 set_anim_state("damage")。
改动 C++ 后已重建 mtgodot 扩展;脚本改动需要重新导出 PCK 才进包。
下一批次:
击退世界碰撞与 IsResistFallen;防御球跟骨骼矩阵;远端 AttackProcess;真实服务器战斗验收。
```
+22 -5
View File
File diff suppressed because one or more lines are too long
+115
View File
@@ -0,0 +1,115 @@
# 首个 Mac 联网内测版:实施状态与已知问题(REL-01)
日期:2026-09-11。对应计划:`docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md`
后续审查:提交 `f39a55fd` 的观察器缓存保留、运行取消清理、外部内存门禁与异常 JSON 处理问题,见 [审查与修复记录](REVIEW-f39a55fd.md)。以下环境数据是原实施时的快照。
本文只记录已核对的事实。**当前没有可放行的候选包,也没有任何实网证据**;所有依赖服务器、测试账号、环境负责人确认或人工签核的条目均为 BLOCKED。离线/假进程测试 PASS 只证明门禁和状态机按契约工作,不能代替联网验收。
## 1. 结论
| 项 | 状态 | 说明 |
| --- | --- | --- |
| INF-01 契约与配置校验 | 离线 PASS | `playable_harness_test.gd` 负向用例、`validate_playable_report.mjs` 静态夹具全部通过 |
| INF-02 运行器与退出门禁 | 离线 PASS | `script/playable_gate_test.sh` 用假签名 arm64 包覆盖 PASS/退出码/信号/超时 TERM→10s→KILL/残留 FIFO/RID 与泄漏警告/凭据脱敏/聚合 |
| NET-01 真实联网闭环 | **BLOCKED** | 包内入口已实现;缺 `scenario.local.json`、测试账号、环境负责人确认的路线/怪物/掉落,未联网运行 |
| CBT-01 四职业技能矩阵 | **BLOCKED** | `test/playable/skill-cases.json` 全部 `unconfirmed`;脚本不补技能 ID |
| MAP-01 树怪资源与离线 Metal 截图 | 离线 PASS**待人工签核** | 12 个候选树怪 × CPU/GPU × wait/run/attack/damage/dead 全部组装通过;截图需人工看过 |
| MAP-02 实际地图定位与落地 | **BLOCKED** | `forest-viewpoints.example.json` 机位均 `unconfirmed`;坡地脚/根接触需人工检查 |
| STB-01 2 小时稳定性 | 工具已实现,**未实跑**,BLOCKED | `script/playable_soak.sh` 可运行(见 §4);真实断网与切图需环境确认 |
| REL-01 放行 | **BLOCKED** | 无重建候选包;上述必测项均未产生实网证据 |
## 2. 候选包与环境
- 计划中的候选包 `build/export-native-trees-20260911/mtgodot-poc.app` 和自编译引擎 `build/godot-particle-diagnostic/bin/godot.macos.template_release.arm64` 在当前工作树**不存在**。仅有 `build/export/mtgodot-poc.app`2026-09-08),早于本轮全部 playable 代码,**不能**作为候选包。
- 本轮修改了 GDScript(包内测试入口、流程、指标、窗口适配),**必须重新导出 PCK**;若最终修复涉及 C++,还需 `./build.sh Release` 重建扩展、重新签名打包。重建后对最终包重新跑退出门禁。
- 官方 Release 模板的粒子退出警告未修复;必须显式使用经过验证的自编译 arm64 引擎。不宣称已修复官方模板或 Intel 平台。
- 数据盘剩余约 7.5 GiB(99% 已用)。2 小时 soak 的 `events.jsonl``rss.jsonl`、截图与日志会持续写盘,实跑前需由用户腾出空间;脚本不会自行清理任何文件或系统缓存。
## 3. 已知问题与限制
### 3.1 树怪动作(MAP-01
- `ent_trent`2301/2311)的 motlist 只有 WAIT、WAIT1、NORMAL_ATTACK、NORMAL_ATTACK1、FRONT_DAMAGE、FRONT_DEAD**没有 RUN/WALK**。按旧客户端 `CRaceData::GetMotionKey` 未命中→`SetLoopMotion` 直接返回的规则,移动时继续播放当前循环(`reference_keep_current_motion`)。`forest_mob_render_test` 报告记为 4 条 fallback2 个 race × CPU/GPU),标注“需实网确认:服务器移动该种族时位置变化且保持原循环”。未经实网确认前不能宣称行走表现与原版一致。
- 七种 ent 目录的 motlist 均只有 FRONT_DEAD,没有 BACK_DEAD。背后击杀时的死亡动作按旧客户端回退规则判定,需实网对照确认。死亡姿态、材质、轮廓已进入接触表截图,**必须人工签核**;AABB 底部不是脚部接触,离线测试不能证明坡地脚/根贴地。
- `mob_winding_test.gd` 仍固定覆盖 101/110/20001;树怪由 `forest_mob_render_test.gd` 单独覆盖。
### 3.2 帧时间(离线合成 suite,非实网)
`rendering_scenario_test.gd`Metalsuite=`offline_synthetic`FakeClient + 真实资源)本轮结果:
- 689 帧,>50ms 14 帧,全部已归因(unattributed=0);>100msloading 6、gameplay 2。
- gameplay 的 2 帧(119.5ms、237.0ms)都带 `viewport_resize` 标记,发生在测试主动切换窗口尺寸时。它们不属于正常游玩的重复交互,但在实网 soak 中要继续按“同一交互 3 次中 2 次 >100ms 即阻断”规则判定。
- loading 段:同步模型构建 447746ms,切图 1861ms 与 4489ms。首次加载预算尚未固定(计划要求基线采集后再定,不临时放宽)。
- CPU 蒙皮 8/16 角色时 p99 分别为 70.1ms/50.5ms90 帧窗口,p99 即最大值);GPU 蒙皮 1/8/16 角色 p99 ≤ 26.1ms。
- 视口 1280×720、1920×1080、2560×1440 均按请求尺寸生效(screen_scale=1);Retina 缩放下的物理/逻辑换算由 `testing/playable_window.gd` 记录,未在 Retina 屏实测。
- 冷启动:进程冷启动/系统缓存状态未知,测试不清空系统缓存。
- 显存:`gpu_memory_kib=null`unavailable)。Godot RenderingDevice 计数 843,616 KiB 不是系统显存,不据此判断显存泄漏。探针开销:每次采样 CPU 约 1.26µs;开/关探针 p50 差 −0.018ms,处于帧节奏噪声内。
### 3.3 稳定性工具的边界(STB-01)
- **切图**`soak.warp.status` 默认 `unconfirmed`STB-WARP-01 为 BLOCKED。只有环境负责人确认合法传送入口(`portal_cm``return_portal_cm``destination_map_key``cross_server`、偶数且 ≥20 的往返次数)后才会走传送门;不以直接改客户端节点位置代替。跨服切图须核实测试服支持。
- **真实断网**`soak.faults.status` 默认 `unconfirmed`STB-DISCONNECT-01 为 BLOCKED;主动 `reconnect()`STB-RECONNECT-01)与真实传输故障分开计数,不能互相替代。确认后由 `run_client_gate.sh` 在 127.0.0.1 上启动 `script/playable_fault_proxy.mjs`,只代理本测试客户端的连接(`server.*` 与 serverlist 必须指向代理端口,`soak.faults.upstream` 为真实测试服)。不修改全局路由、防火墙,也不关闭任何服务。
- **已知限制**:如果服务器传送时下发了直连的 game 地址,之后的连接会**绕过代理**,故障注入对切图后的连接无效。需要在确认故障条件时一并核实,或在同一地图服务器内完成故障用例。
- **窗口**:headless、全屏、最大化窗口不可切换尺寸;请求尺寸(含标题栏)超出屏幕可用区域时如实记为 BLOCKED,不缩小目标冒充通过。
- **内存**:父运行器每秒采样子进程 RSS(KiB),在每轮静置 30s 后取低水位;必须 ≥ warm-up + 10 轮才判定。连续 5 轮增长,且后 5 轮中位数比前 5 轮高出 max(50MiB, 5%) 时 FAIL。低于阈值也不能证明长期无泄漏。
- 10 次正常退出由 10 个独立进程完成,每次都经过完整退出门禁;任何一次失败,后续运行(包括 2 小时 soak)不会启动,记为 BLOCKED。
### 3.4 人工项(不能自动化替代)
- 真实鼠标/键盘闭环(点击移动、选怪、普攻、技能栏、拾取)需要单独的人工证据。
- MAP-01/MAP-02 Metal 截图人工签核(`forest_map_render_test` 报告里 `manual_signoff.status` 恒为 `PENDING`,自动 PASS 不代表视觉签核)。
- CBT-01 技能特效截图需人工看过一次。
## 4. 已可运行的命令
以下命令在 `metin2-client/` 下执行。账号只通过 `MT_ACCOUNT`/`MT_PASSWORD` 环境变量或隐藏输入提供,禁止写进配置、参数或命令历史。
离线(不连服务器,本轮均 PASS):
```bash
bash script/playable_gate_test.sh
node script/playable_soak_metrics_test.mjs
node script/playable_fault_proxy_test.mjs
node script/audit_playable_maps_test.mjs
(cd project && godot --headless --path . --script playable_harness_test.gd)
(cd project && godot --path . --script forest_mob_render_test.gd)
bash script/rendering_batch_test.sh
```
联网(需要重建候选包、`test/playable/scenario.local.json` 与测试账号;**本轮未运行**):
```bash
bash script/playable_test.sh --app "$PWD/build/export-playable-rc1/mtgodot-poc.app" --config test/playable/scenario.local.json --suite full --allow-gameplay --repeat 3
bash script/playable_soak.sh --app "$PWD/build/export-playable-rc1/mtgodot-poc.app" --config test/playable/scenario.local.json --allow-gameplay --duration-seconds 7200
node script/validate_playable_report.mjs --release-dir <本批次输出目录>
```
`playable_soak.sh` 行为:
- 先用 soak suite 校验配置和资源。缺少 `--allow-gameplay` 时只做校验,返回 2,不启动客户端。
- 依次运行 `exit-1..exit-N`suite playableN ≥ 10)和 `soak-1`suite soak,墙钟时长 = `soak.duration_seconds`,超时 ≥ 时长 + 900s)。
- 写出只列本批次运行的 `release-manifest.json`required_runs `{playable:N, soak:1}`),再聚合。
- 退出码:0 PASS / 1 FAIL / 2 BLOCKED / 124 超时。
## 5. 放行检查表对照(计划 §11)
| 检查项 | 当前 |
| --- | --- |
| INF-01/02 负向用例通过,必测项缺失不能 PASS | ✅ 离线通过 |
| 同一候选包 3 轮联网闭环 + 鼠标键盘证据 | ❌ BLOCKED(无候选包、无账号/夹具、无人工证据) |
| 四职业基础战斗、技能及异常路径 | ❌ BLOCKED(技能矩阵未确认) |
| 首测地图和树怪资源/动作/Metal 画面 | ⚠️ 离线组装通过;机位未确认、人工签核未做、实网未跑 |
| 2 小时、切图、故障恢复、窗口、退出次数 | ❌ BLOCKED(工具就绪,未实跑;切图/断网条件未确认) |
| P0/P1 清零 | ❌ 未知:没有实网运行,无法判定 |
| 所有 BLOCKED 明确处理 | ⚠️ 本文已列出;首测必测项不得以“已知问题”绕过 |
| 包哈希、签名、引擎来源、设备信息一致 | ❌ 需重建候选包后由运行器生成 |
| 已知 P2、未覆盖地图/装备/技能及未验收平台列出 | ⚠️ 本文 §3;未覆盖:首测三图以外的地图、Intel、官方模板 |
## 6. 需要环境负责人提供
1. 自编译 arm64 引擎和重新导出的候选包(含本轮脚本),以及足够的磁盘空间。
2. `test/playable/scenario.local.json`:由 `scenario.example.json` 复制,填写 serverinfo 中的地址、角色槽位、map_key、安全路线、怪物/掉落 vnumsoak 需 `timeout_seconds ≥ duration_seconds + 900`
3. 专用测试账号(等级、装备、已学技能、掉落条件由负责人准备),以及 `skill-cases.local.json` 中各职业的确认记录。
4. 如需 STB-WARP/STB-DISCONNECT:合法传送入口的确认;确认可在 127.0.0.1 代理后运行,并核实传送是否下发直连地址。
5. 树怪机位(平地/坡地/密林/传送点)的确认,以及 Metal 截图人工签核人。
+38
View File
@@ -0,0 +1,38 @@
# f39a55fd 审查与修复
审查对象:`f39a55fdd5f14cb2d14d67929cf45f438f572a06`。修复基于该提交,位于本地分支 `fix/playable-f39a55fd`
## 已确认的问题
| 优先级 | 问题与触发条件 | 影响 | 修复 |
| --- | --- | --- | --- |
| P1 | `PlayableProbe._connections` 用强引用保存 `EffectRegistry`;重连或切图重建注册器 | 旧注册器及其解析缓存一直保留,观察器自身污染长跑内存结果 | 弱引用订阅源;重新绑定和统计时移除失效订阅 |
| P1 | 终止联网/地图运行器或批量入口时,没有完整的子进程清理 | 客户端可能继续联网执行操作,代理、日志处理器、采样器可能残留 | 共用进程清理模块;保留信号退出码,仅对仍持有的子 PID 执行 TERM、有界等待、KILL 和 wait;批量入口转发终止 |
| P1 | 外部 RSS 检查尚未写回时,已有干净退出的中间 `report.json`;之后直接聚合 | 缺失 `STB-MEMORY-01` 的 soak 仍可被发布汇总判 PASS | 汇总 soak PASS 时必须同时存在唯一的内存 PASS 用例和 PASS verdict |
| P2 | 客户端报告、事件行或发布清单包含合法 JSON `null` 等错误类型 | 校验器抛出异常,无法生成应有的失败报告 | 校验 JSON 根类型、事件类型和单调时间字段;错误输入写出 FAIL 报告 |
## 复现证据与回归入口
- 注册器释放用例:模拟 30 次注册器替换。原实现出现 59 项断言失败;修复后 `playable_harness_test: failures=0`
- 取消运行用例:原联网和地图运行器均在收到 TERM 后留下客户端;回归检查终止入口后客户端已被回收,入口退出码为 143,并覆盖普通批量与 soak 入口。
- 发布汇总用例:`node script/playable_release_test.mjs --baseline` 读取原提交的校验器,复现缺少内存检查仍然 PASS(该命令预期断言失败)。正常命令使用修复后的校验器。
- 异常 JSON 用例:检查返回失败码的同时,要求实际生成失败报告,避免只根据异常退出码误判测试通过。
可重复运行:
```bash
godot --headless --path project --script playable_harness_test.gd
bash script/playable_gate_test.sh
node script/playable_release_test.mjs
node script/playable_fault_proxy_test.mjs
node script/playable_soak_metrics_test.mjs
node script/audit_playable_maps_test.mjs
bash script/rendering_batch_test.sh
git diff --check
```
本轮修复后结果:门禁夹具 126 项检查通过;渲染批处理 53 项通过、`failures=0`;注册器/联网状态机、发布汇总、故障代理、内存指标、地图资源审计测试通过;Shell/Node 语法检查及 `git diff --check` 通过。
渲染批处理日志:`build/rendering/batch-20260911-211958-82865/`
这些是离线、回环代理和假签名客户端测试。实网战斗、真实两小时长跑、最终新包与人工画面对照仍需独立验收;本次审查不会将其改成已通过。
@@ -168,6 +168,10 @@ private:
if (header == HDR_GC_MARK_BLOCK) {
return receive_block(packet, available);
}
if (header == HDR_GC_MARK_DIFF_DATA) {
// GuildMarkDownloader.cpp: sizeof(BYTE), dispatch returns true.
return raw(ClassicStream::RawPacketStatus::Consumed, 1);
}
return raw(ClassicStream::RawPacketStatus::NotHandled);
}
+25 -4
View File
@@ -326,12 +326,20 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
p.anti_flags, p.sockets, a);
return true;
}
case HDR_GC_ITEM_DEL: { // 20 — inventory pos only
GCItemDel p;
case HDR_GC_ITEM_DEL: { // 20 — TPacketGCItemDelDeprecated, read as ITEM_SET by 40250
GCItemDelDeprecated p;
if (!fill(p, header, body, len)) {
return false;
}
m_world.mut_item_del(/*WINDOW_INVENTORY*/ 1, p.pos);
if (p.vnum == 0) {
m_world.mut_item_del(p.cell.window_type, p.cell.cell);
return true;
}
mtnet::ItemAttr a[ITEM_ATTRIBUTE_MAX_NUM];
for (int i = 0; i < ITEM_ATTRIBUTE_MAX_NUM; ++i) {
a[i] = {p.attrs[i].type, p.attrs[i].value};
}
m_world.mut_item_set(p.cell.window_type, p.cell.cell, p.vnum, p.count, 0, 0, p.sockets, a);
return true;
}
case HDR_GC_ITEM_UPDATE: { // 25
@@ -1331,7 +1339,20 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
m_world.mut_observer(ObserverEvent::Remove, packet.vid, 0, 0);
return true;
}
case HDR_GC_REFINE_INFORMATION_OLD:
case HDR_GC_REFINE_INFORMATION_OLD: { // 95 — RecvRefineInformationPacket, no type
GCRefineInfoOld packet;
if (!fill(packet, header, body, len)) {
return false;
}
RefineCue::Mat materials[5] = {};
for (int i = 0; i < 5; ++i) {
materials[i].vnum = packet.materials[i].vnum;
materials[i].count = packet.materials[i].count;
}
m_world.mut_refine(0, packet.pos, packet.src_vnum, packet.result_vnum,
packet.material_count, packet.cost, packet.prob, materials);
return true;
}
case HDR_GC_REFINE_INFORMATION: {
GCRefineInfo packet;
if (!fill(packet, header, body, len)) {
@@ -796,12 +796,8 @@ bool ClassicSession::send_use_skill(uint32_t skill_vnum, uint32_t target_vid) {
if (m_stage != Stage::InGame) {
return false;
}
if (target_vid) {
CGFlyTargeting ft{};
ft.header = HDR_CG_FLY_TARGETING;
ft.target_vid = target_vid;
m_stream.send_fixed(&ft, sizeof(ft));
}
// PythonNetworkStreamPhaseGame.cpp SendUseSkillPacket: only TPacketCGUseSkill.
// Fly targets are sent by the skill caller (CG_ADD_FLY_TARGETING / OnSetFlyTarget).
CGUseSkill p{};
p.header = HDR_CG_USE_SKILL;
p.vnum = skill_vnum;
@@ -518,6 +518,16 @@ void ClassicStream::dispatch() {
}
framed = static_cast<uint32_t>(
quest_info_packet_size(head[QUEST_INFO_HEAD_SIZE - 1]));
} else if (header == HDR_GC_GUILD) {
// GUILD_SUBHEADER_GC_SKILL_INFO reports one byte more than the
// server writes (see GUILD_SKILL_INFO_PACKET_SIZE).
uint8_t head[sizeof(DynHead) + 1];
if (!m_recv.peek(head, sizeof(head))) {
return;
}
if (head[sizeof(DynHead)] == GUILD_SUBHEADER_GC_SKILL_INFO) {
framed = GUILD_SKILL_INFO_PACKET_SIZE;
}
}
if (m_recv.readable() < framed) {
return; // whole packet not here yet
+38 -3
View File
@@ -282,6 +282,7 @@ enum : uint8_t {
HDR_GC_OBSERVER_MOVE = 98,
HDR_GC_VIEW_EQUIP = 99,
HDR_GC_MARK_BLOCK = 100,
HDR_GC_MARK_DIFF_DATA = 101, // mark connection only: bare header, ignored
HDR_GC_MARK_IDXLIST = 102,
HDR_GC_TIME = 106,
HDR_GC_CHANGE_NAME = 107,
@@ -944,6 +945,19 @@ struct GCViewEquip {
};
struct GCChangeName { uint8_t header; uint32_t pid; char name[CHARACTER_NAME_MAX_LEN + 1]; };
struct GCRefineMaterial { uint32_t vnum; int32_t count; };
// Header 95: client TPacketGCRefineInformation = [hdr][pos][TRefineTable], no type.
struct GCRefineInfoOld {
uint8_t header;
uint8_t pos;
uint32_t src_vnum;
uint32_t result_vnum;
uint8_t material_count;
int32_t cost;
int32_t prob;
GCRefineMaterial materials[5];
};
static_assert(sizeof(GCRefineInfoOld) == 59);
// Header 119: server TPacketGCRefineInformation == client TPacketGCRefineInformationNew.
struct GCRefineInfo {
uint8_t header;
uint8_t type;
@@ -1148,9 +1162,22 @@ struct GCItemSet { // packet_item_set (server packet.h:1134, header 21)
};
static_assert(sizeof(GCItemSet) == 1 + 3 + 4 + 1 + 4 + 4 + 1 + 12 + 21); // 51
struct GCItemDel { uint8_t header; uint8_t pos; }; // header 20 — inventory pos only
struct GCItemDel { uint8_t header; uint8_t pos; }; // TPacketGCItemDel — SAFEBOX_DEL / MALL_DEL
static_assert(sizeof(GCItemDel) == 2);
// Header 20: the 40250 server sends TPacketGCItemDelDeprecated (packet.h:1124,
// char_item.cpp:424) when an inventory cell empties; the 40250 client reads the
// same 42 bytes as its HEADER_GC_ITEM_SET (no flags/anti_flags/highlight).
struct GCItemDelDeprecated {
uint8_t header;
ItemPos cell;
uint32_t vnum;
uint8_t count;
int32_t sockets[ITEM_SOCKET_MAX_NUM];
ItemAttr3 attrs[ITEM_ATTRIBUTE_MAX_NUM];
};
static_assert(sizeof(GCItemDelDeprecated) == 1 + 3 + 4 + 1 + 12 + 21); // 42
// GC_MESSENGER is dynamic: after [header][u16 size], body starts with the
// subheader and then the subheader-specific records below.
struct GCMessengerHead { uint8_t header; uint16_t size; uint8_t subheader; };
@@ -1249,6 +1276,14 @@ static_assert(sizeof(GuildMember38) == 38 && sizeof(GuildInfo35) == 35 &&
sizeof(GuildName16) == 16 && sizeof(GuildSkill17) == 17 &&
sizeof(GuildComment80) == 80 && sizeof(GuildInvite17) == 17);
// CGuild::SendSkillInfoPacket() (guild.cpp) announces
// `size = sizeof(pack) + 6 + GUILD_SKILL_COUNT` (22) but writes only
// skill_point, abySkill[12], power(2) and max_power(2) after the head — 21
// bytes. The original client's RecvGuild() reads those fields one by one, so
// framing off the size field would swallow the next packet's header byte.
inline constexpr uint8_t GUILD_SUBHEADER_GC_SKILL_INFO = 12;
inline constexpr int GUILD_SKILL_INFO_PACKET_SIZE = 4 + sizeof(GuildSkill17); // 21
// --- chat (dynamic) ---
struct CGChatHead { uint8_t header; uint16_t length; uint8_t type; }; // + char szChat[]
static_assert(sizeof(CGChatHead) == 4);
@@ -1382,7 +1417,7 @@ constexpr int packet_size_gc(uint8_t h) {
case HDR_GC_CHARACTER_POINTS: return sizeof(GCPoints);
case HDR_GC_CHARACTER_POINT_CHANGE: return sizeof(GCPointChange);
case HDR_GC_ITEM_SET: return sizeof(GCItemSet);
case HDR_GC_ITEM_DEL: return sizeof(GCItemDel);
case HDR_GC_ITEM_DEL: return sizeof(GCItemDelDeprecated);
case HDR_GC_ITEM_USE: return sizeof(GCItemUse);
case HDR_GC_ITEM_UPDATE: return sizeof(GCItemUpdate);
case HDR_GC_ITEM_GROUND_ADD: return sizeof(GCItemGroundAdd);
@@ -1447,7 +1482,7 @@ constexpr int packet_size_gc(uint8_t h) {
case HDR_GC_LOVE_POINT_UPDATE: return sizeof(GCLovePointUpdate);
case HDR_GC_DIG_MOTION: return sizeof(GCDigMotion);
case HDR_GC_VIEW_EQUIP: return sizeof(GCViewEquip);
case HDR_GC_REFINE_INFORMATION_OLD:
case HDR_GC_REFINE_INFORMATION_OLD: return sizeof(GCRefineInfoOld);
case HDR_GC_REFINE_INFORMATION: return sizeof(GCRefineInfo);
case HDR_GC_DRAGON_SOUL_REFINE: return sizeof(GCDragonSoulRefine);
case HDR_GC_QUEST_CONFIRM: return sizeof(GCQuestConfirm);
+136 -23
View File
@@ -44,6 +44,17 @@ void dbg_ignored(const char *what, uint32_t id) {
}
}
// Server GetDegreeFromPosition (game/src/vector.cpp): compass heading of a
// server-frame direction, 0 = +Y, clockwise toward +X, in [0, 360). Same frame
// as the decoded GC_MOVE bRot, i.e. Entity::angle.
float heading_deg(float dx, float dy) {
float deg = std::atan2(dx, dy) * (180.0f / 3.14159265f);
if (deg < 0.0f) {
deg += 360.0f;
}
return deg;
}
} // namespace
Entity &EntityStore::touch(uint32_t vid, bool &created) {
@@ -240,7 +251,11 @@ void EntityStore::begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_f
if (dur == 0) {
dur = 1; // never a zero-length walk; tick() finishes it next frame
}
e.angle = c.rot;
// Reference MovementProcess faces a walking remote actor along Src->Dst
// (SetAdvancingRotation from the pixel positions); the packet's bRot is only
// applied once it stops (m_fDstRot). Server Follow() can send a bRot that
// faces the victim while Dst is a flank point, so using bRot here slides.
e.angle = dist > 0.0f ? heading_deg(dx, dy) : c.rot;
e.moving = true;
e.func = FUNC_MOVE;
e.sx = e.x;
@@ -3555,37 +3570,135 @@ void EntityStore::tick() {
// CLIENT-GAP §3.2: release any TCP state commands that have come due, then
// advance the walk. A queued command released here starts its walk this tick.
process_states();
uint32_t dt_ms = m_ticked && m_now > m_last_tick_ms ? m_now - m_last_tick_ms : 0;
if (dt_ms > MOVE_MAX_TICK_MS) {
dt_ms = MOVE_MAX_TICK_MS;
}
m_last_tick_ms = m_now;
m_ticked = true;
for (auto &kv : m_ents) {
Entity &e = kv.second;
if (!e.moving) {
continue;
}
float t = e.move_dur_ms == 0
? 1.0f
: (float)(m_now - e.move_start_ms) / (float)e.move_dur_ms;
if (t <= 0.0f) {
t = 0.0f;
const float speed = motion_move_speed(e);
if (speed > 0.0f) {
advance_walk_by_motion(e, speed, dt_ms);
} else {
advance_walk_by_duration(e);
}
if (t >= 1.0f) {
e.x = e.tx;
e.y = e.ty;
}
}
void EntityStore::set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
dbg_ignored("set_motion_speed", vid);
return;
}
it->second.walk_motion_speed = walk_cm_s > 0.0f ? walk_cm_s : 0.0f;
it->second.run_motion_speed = run_cm_s > 0.0f ? run_cm_s : 0.0f;
}
// Reference CActorInstance::Move(): SetLoopMotion(m_isWalking ? WALK : RUN, 0.15,
// m_fMovSpd) and CInstanceBase::SetMoveSpeed: m_fMovSpd = movSpd / 100 (> 1100 ->
// 0). The walk then advances by that motion's accumulation. Deviations: movSpd 0
// (not sent yet) counts as 100, and a zero factor (> 1100) falls back to the
// dwDuration lerp instead of freezing the actor mid-walk. The pushed speeds are
// the on-foot motions, so a mounted actor (horse motion set not measured) also
// uses the lerp.
float EntityStore::motion_move_speed(const Entity &e) {
const float base = e.walk_mode == WALKMODE_WALK ? e.walk_motion_speed : e.run_motion_speed;
if (base <= 0.0f || e.moving_speed > 1100 || e.mount_vnum != 0) {
return 0.0f;
}
const float factor = e.moving_speed == 0 ? 1.0f : (float)e.moving_speed / 100.0f;
return base * factor;
}
// Reference m_kMovAfterFunc switch on arrival: COMBO / ATTACK / MOB_SKILL snap
// to Dst and act; everything else snaps to Dst, faces m_fDstRot and stops.
void EntityStore::finish_walk(Entity &e, bool snap_to_dst) {
if (snap_to_dst) {
e.x = e.tx;
e.y = e.ty;
}
e.moving = false;
e.skip_collision = false;
if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK ||
e.mov_after_func == FUNC_MOB_SKILL) {
e.func = e.mov_after_func;
} else {
e.func = FUNC_WAIT;
}
e.angle = e.mov_after_rot;
e.mov_after_func = FUNC_WAIT;
}
// Reference CInstanceBase::MovementProcess (non-main branch). Src/Dst are the
// server-frame points from the state command; Cur advances along Src->Dst by
// the motion speed and "arrives" on the frame whose step crosses Dst.
void EntityStore::advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms) {
float dir_x = e.tx - e.sx;
float dir_y = e.ty - e.sy;
float total = std::sqrt(dir_x * dir_x + dir_y * dir_y);
if (total > 0.0f) {
e.angle = heading_deg(dir_x, dir_y);
dir_x /= total;
dir_y /= total;
}
const float step = speed * (float)dt_ms / 1000.0f;
const float next_x = e.x + dir_x * step;
const float next_y = e.y + dir_y * step;
const float cx = e.x - e.sx, cy = e.y - e.sy;
const float nx = next_x - e.sx, ny = next_y - e.sy;
const float cur_len = std::sqrt(cx * cx + cy * cy);
const float next_len = std::sqrt(nx * nx + ny * ny);
if (total - cur_len < -MOVE_OVERSHOOT_CM) {
// latency overran Dst: re-source here, turn back toward Dst, and stop at
// the next arrival instead of carrying on with FUNC_MOVE.
e.sx = e.x;
e.sy = e.y;
e.angle = heading_deg(e.tx - e.x, e.ty - e.y);
if (e.mov_after_func == FUNC_MOVE) {
e.mov_after_func = FUNC_WAIT;
}
} else if (cur_len <= total && total <= next_len) {
if (e.dead || e.knock_down) {
e.moving = false;
e.skip_collision = false;
// reference m_kMovAfterFunc: once the walk reaches Dst, run the
// action the state packet asked for (COMBO / ATTACK / MOB_SKILL);
// otherwise settle to WAIT.
if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK ||
e.mov_after_func == FUNC_MOB_SKILL) {
e.func = e.mov_after_func;
e.angle = e.mov_after_rot;
} else {
e.func = FUNC_WAIT;
}
e.mov_after_func = FUNC_WAIT;
} else {
e.x = e.sx + (e.tx - e.sx) * t;
e.y = e.sy + (e.ty - e.sy) * t;
return;
}
if (e.mov_after_func == FUNC_MOVE) {
// FUNC_MOVE: keep walking past Dst until the next state command (or
// the overshoot guard above) — this is what keeps a moving remote PC
// from stop-starting between its periodic move packets.
e.x = next_x;
e.y = next_y;
return;
}
finish_walk(e, true);
return;
}
e.x = next_x;
e.y = next_y;
}
// Fallback when the actor's motion speed is unknown: lerp Src->Dst over the
// server-supplied dwDuration and stop on time.
void EntityStore::advance_walk_by_duration(Entity &e) {
float t = e.move_dur_ms == 0
? 1.0f
: (float)(m_now - e.move_start_ms) / (float)e.move_dur_ms;
if (t <= 0.0f) {
t = 0.0f;
}
if (t >= 1.0f) {
finish_walk(e, true);
} else {
e.x = e.sx + (e.tx - e.sx) * t;
e.y = e.sy + (e.ty - e.sy) * t;
}
}
+33 -1
View File
@@ -51,6 +51,20 @@ struct StateCmd {
// CLIENT-GAP-FIX.md §3.2.
static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000;
// GC_WALK_MODE / TPacketGCWalkMode::mode (40250 Packet.h:2327, server packet.h:1946).
enum : uint8_t {
WALKMODE_RUN = 0,
WALKMODE_WALK = 1,
};
// Reference CInstanceBase::MovementProcess (non-main branch): a remote actor that
// has walked more than this far past its Dst re-sources the walk at its current
// position, turns back toward Dst and downgrades a pending FUNC_MOVE to FUNC_WAIT.
static constexpr float MOVE_OVERSHOOT_CM = 100.0f;
// Cap on one tick's walk step so a hitch (or the first tick after a long pause)
// cannot teleport an actor far past its Dst in a single frame.
static constexpr uint32_t MOVE_MAX_TICK_MS = 250;
struct Entity {
uint32_t vid = 0;
uint16_t race = 0;
@@ -65,7 +79,7 @@ struct Entity {
uint8_t func = FUNC_WAIT;
uint8_t position = 0;
uint8_t walk_mode = 0; // 0 walk, 1 run (server's WALKMODE_*)
uint8_t walk_mode = WALKMODE_RUN; // server WALKMODE_* (0 run, 1 walk)
uint32_t fly_target_vid = 0;
int32_t fly_target_x = 0, fly_target_y = 0;
bool fly_target_set = false;
@@ -74,6 +88,14 @@ struct Entity {
float sx = 0, sy = 0, tx = 0, ty = 0;
uint32_t move_start_ms = 0;
uint32_t move_dur_ms = 0;
// Root-motion speed of the race's WALK / RUN loop at movSpd 100, in cm/s
// (.msa Accumulation length / MotionDuration). Pushed by the presentation
// layer once the model is built (set_motion_speed). While known, a remote
// walk advances like the reference CActorInstance::AccumulationMovement
// (motion speed * movSpd/100) and arrives by crossing Dst; while 0 (no model /
// headless) it falls back to lerping over the server dwDuration.
float walk_motion_speed = 0;
float run_motion_speed = 0;
// CLIENT-GAP §3.2: TCP state queue + the "do this once the walk reaches Dst"
// latch (reference m_kMovAfterFunc / m_dwMovAfterArg). `skip_collision` mirrors
@@ -516,6 +538,9 @@ public:
// and again right after mut_move() enqueues, so an already-due command applies
// synchronously.
void process_states();
// Root-motion speeds (cm/s at movSpd 100) of this actor's WALK / RUN loops;
// 0 = unknown. See Entity::walk_motion_speed. No-op for an unknown VID.
void set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s);
// Feed one complete game-phase packet. `body` points at the packet start
// (header/length included); `len` == that length. Unknown headers ignored.
@@ -1176,11 +1201,18 @@ private:
void begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func); // Src/Dst + skip-collision
static bool can_process_network_state(const Entity &e); // ~ __CanProcessNetworkStatePacket
static bool is_enable_tcp_process(const Entity &e, uint8_t func); // ~ __IsEnableTCPProcess
// Remote-walk advance (reference CInstanceBase::MovementProcess, non-main).
static float motion_move_speed(const Entity &e); // cm/s, 0 = use dwDuration lerp
static void finish_walk(Entity &e, bool snap_to_dst); // m_kMovAfterFunc on arrival
void advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms);
void advance_walk_by_duration(Entity &e);
std::unordered_map<uint32_t, Entity> m_ents;
uint32_t m_main_vid = 0;
uint32_t m_now = 0;
uint32_t m_server_frame_ms = 0;
uint32_t m_last_tick_ms = 0; // m_now at the previous tick() (motion-driven walk dt)
bool m_ticked = false;
std::vector<Change> m_changes;
std::vector<uint32_t> m_dirty;
std::string m_bgm_name;
+9
View File
@@ -100,6 +100,8 @@ void M2Client::_bind_methods() {
ClassDB::bind_method(D_METHOD("quest_cancel"), &M2Client::quest_cancel);
ClassDB::bind_method(D_METHOD("get_quests"), &M2Client::get_quests);
ClassDB::bind_method(D_METHOD("get_entity", "vid"), &M2Client::get_entity);
ClassDB::bind_method(D_METHOD("set_entity_motion_speed", "vid", "walk_cm_s", "run_cm_s"),
&M2Client::set_entity_motion_speed);
ClassDB::bind_method(D_METHOD("get_entities"), &M2Client::get_entities);
ClassDB::bind_method(D_METHOD("get_main_vid"), &M2Client::get_main_vid);
ClassDB::bind_method(D_METHOD("get_main_pid"), &M2Client::get_main_pid);
@@ -2523,6 +2525,13 @@ const mtnet::EntityStore *M2Client::active_world() const {
return game ? &game->world() : nullptr;
}
void M2Client::set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s) {
mtnet::EntityStore *w = classic_sess ? &classic_sess->world() : (game ? &game->world() : nullptr);
if (w) {
w->set_motion_speed((uint32_t)vid, (float)walk_cm_s, (float)run_cm_s);
}
}
Dictionary M2Client::get_entity(int vid) const {
const mtnet::EntityStore *w = active_world();
if (!w) {
+3
View File
@@ -288,6 +288,9 @@ public:
// --- networked world snapshot (positions already Godot-space, metres) ---
godot::Dictionary get_entity(int vid) const;
// Root-motion speeds (cm/s at movSpd 100) of a remote actor's WALK / RUN loop,
// measured by the view from its .msa; drives the reference-style remote walk.
void set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s);
godot::Array get_entities() const;
int get_main_vid() const;
int get_main_pid() const;
@@ -59,6 +59,12 @@ static void test_mark_download() {
assert(request.size() == sizeof(CGMarkIDXList));
assert(request[0] == HDR_CG_MARK_IDXLIST);
// GuildMarkDownloader.cpp:172/201 frames HEADER_GC_MARK_DIFF_DATA (101) as a
// bare header byte and ignores it; the next mark frame must still parse.
const uint8_t diff = 101;
client.stream().feed(&diff, 1);
assert(client.stream().last_error().empty());
// One guild maps to image zero, position one. Feed the whole-size frame in
// two chunks to exercise the custom raw framing path.
std::vector<uint8_t> idx(sizeof(GCMarkIDXList) + 4);
+125 -16
View File
@@ -183,6 +183,26 @@ int main() {
"on_char_list fired with renamed slot");
}
// --- GC 9 create failure: input_login.cpp:442-465 sends a zeroed 10-byte
// TPacketGCLoginFailure under header 9 (blocked creation / bad name). The 40250
// client reads TPacketGCCreateFailure (2 bytes) and PythonNetworkStream.cpp:509
// skips the trailing zero bytes as blank headers. input_db.cpp:196 sends 2 bytes. ---
{
s.parser().drain_char_events();
std::vector<uint8_t> cf(10, 0);
cf[0] = HDR_GC_CREATE_FAILURE;
GCPlayerCreateFailure dup{HDR_GC_CREATE_FAILURE, 1};
std::vector<uint8_t> db = raw(dup);
cf.insert(cf.end(), db.begin(), db.end());
feed(s, cf);
auto ev = s.parser().drain_char_events();
CHECK(ev.size() == 2 && ev[0].kind == ClassicParser::CharEvent::CreateFail &&
ev[0].fail_type == 0 && ev[1].kind == ClassicParser::CharEvent::CreateFail &&
ev[1].fail_type == 1,
"10-byte and 2-byte GC 9 create failures both decode like 40250");
CHECK(s.last_error().empty(), "no desync after 10-byte GC 9");
}
// --- select_char(0) -> CG_CHARACTER_SELECT + seq[1] ---
{
CHECK(s.select_char(0), "select_char(0) ok");
@@ -523,9 +543,84 @@ int main() {
feed(s, raw(iu));
CHECK(s.world().item_slot(1, 4).count == 3, "item_update count -> 3");
GCItemDel id{HDR_GC_ITEM_DEL, 4};
feed(s, raw(id));
// GC 20 is TPacketGCItemDelDeprecated on the 40250 server (char_item.cpp:424,
// packet.h:1124): header, TItemPos, vnum, count, alSockets[3], aAttr[7] = 42
// bytes; the 40250 client reads it as ITEM_SET. Sent on inventory load, so feed
// it glued to the next packet: a short frame desyncs the loading stream.
std::vector<uint8_t> del(42, 0);
del[0] = HDR_GC_ITEM_DEL;
del[1] = 1; // WINDOW_INVENTORY
del[2] = 4; // cell 4 (u16 LE)
GCItemSet next{};
next.header = HDR_GC_ITEM_SET;
next.cell = {1, 7};
next.vnum = 11200;
next.count = 2;
std::vector<uint8_t> glued = del;
std::vector<uint8_t> nb = raw(next);
glued.insert(glued.end(), nb.begin(), nb.end());
feed(s, glued);
CHECK(s.world().item_slot(1, 4).vnum == 0, "item_del cleared inventory cell 4");
CHECK(s.world().item_slot(1, 7).vnum == 11200 && s.world().item_slot(1, 7).count == 2,
"packet after 42-byte GC 20 framed correctly");
CHECK(s.last_error().empty(), "no desync after GC 20");
// Same wire layout with a vnum: the 40250 client applies it as an item set.
std::vector<uint8_t> set20(42, 0);
set20[0] = HDR_GC_ITEM_DEL;
set20[1] = 1;
set20[2] = 9;
const uint32_t vnum20 = 27001;
std::memcpy(&set20[4], &vnum20, 4);
set20[8] = 5; // count
const int32_t sock0 = 28030;
std::memcpy(&set20[9], &sock0, 4);
set20[21] = 7; // aAttr[0].bType
const int16_t av = 15;
std::memcpy(&set20[22], &av, 2);
feed(s, set20);
const mtnet::Item &it20 = s.world().item_slot(1, 9);
CHECK(it20.vnum == 27001 && it20.count == 5 && it20.sockets[0] == 28030 &&
it20.attrs[0].type == 7 && it20.attrs[0].value == 15,
"GC 20 with vnum sets the cell like the 40250 client");
s.world().mut_item_del(1, 9);
}
// --- GC 95 HEADER_GC_REFINE_INFORMATION: the 40250 client registers it with
// TPacketGCRefineInformation = [hdr][pos][TRefineTable] = 59 bytes (no type byte;
// only 119 _NEW carries type). Glue a packet after it to prove the frame length. ---
{
s.world().drain_refine_cues();
std::vector<uint8_t> rf(59, 0);
rf[0] = 95;
rf[1] = 6; // pos
const uint32_t src = 11209, dst = 11210, mat_vnum = 30053;
const int32_t cost = 5000, prob = 90, mat_count = 2;
std::memcpy(&rf[2], &src, 4);
std::memcpy(&rf[6], &dst, 4);
rf[10] = 1; // material_count
std::memcpy(&rf[11], &cost, 4);
std::memcpy(&rf[15], &prob, 4);
std::memcpy(&rf[19], &mat_vnum, 4);
std::memcpy(&rf[23], &mat_count, 4);
GCItemSet after{};
after.header = HDR_GC_ITEM_SET;
after.cell = {1, 11};
after.vnum = 11210;
after.count = 1;
std::vector<uint8_t> ab = raw(after);
rf.insert(rf.end(), ab.begin(), ab.end());
feed(s, rf);
auto cues = s.world().drain_refine_cues();
CHECK(cues.size() == 1 && cues[0].type == 0 && cues[0].pos == 6 &&
cues[0].src_vnum == 11209 && cues[0].result_vnum == 11210 &&
cues[0].material_count == 1 && cues[0].cost == 5000 &&
cues[0].prob == 90 && cues[0].materials[0].vnum == 30053 &&
cues[0].materials[0].count == 2,
"GC 95 refine information uses the 59-byte 40250 layout");
CHECK(s.world().item_slot(1, 11).vnum == 11210, "packet after GC 95 framed correctly");
CHECK(s.last_error().empty(), "no desync after GC 95");
s.world().mut_item_del(1, 11);
}
// --- GC entity state: UPDATE(19) / CHANGE_SPEED(18) / POSITION(43) / MOTION(36) /
@@ -743,22 +838,26 @@ int main() {
cf[0].request_pid == 555,
"quest confirm cue");
// GC_QUEST_INFO dynamic: [hdr 81][u16 size][u16 index][u8 flag][TITLE\0][COUNTER_NAME\0][i32]
std::vector<uint8_t> qb;
// GC_QUEST_INFO (questpc.cpp PC::SendQuestInfoPakcet): [hdr 81][u16 size][u16 index]
// [u8 flag] then fixed-width optional fields (title 31, counter name 17, i32). The
// head is written before size is bumped, so the wire size stays 6.
std::vector<uint8_t> qpkt(QUEST_INFO_HEAD_SIZE, 0);
qpkt[0] = HDR_GC_QUEST_INFO;
uint16_t qsz = QUEST_INFO_HEAD_SIZE;
std::memcpy(&qpkt[1], &qsz, 2);
uint16_t qidx = 3;
qb.insert(qb.end(), (uint8_t *)&qidx, (uint8_t *)&qidx + 2);
uint8_t qflag = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE;
qb.push_back(qflag);
const char *qt = "Kill 10 wolves";
qb.insert(qb.end(), qt, qt + std::strlen(qt) + 1);
const char *cn = "Wolves";
qb.insert(qb.end(), cn, cn + std::strlen(cn) + 1);
std::memcpy(&qpkt[3], &qidx, 2);
qpkt[5] = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE;
std::vector<uint8_t> qt(QUEST_INFO_TITLE_SIZE, 0);
std::memcpy(qt.data(), "Kill 10 wolves", 14);
qpkt.insert(qpkt.end(), qt.begin(), qt.end());
std::vector<uint8_t> cn(QUEST_INFO_COUNTER_NAME_SIZE, 0);
std::memcpy(cn.data(), "Wolves", 6);
qpkt.insert(qpkt.end(), cn.begin(), cn.end());
int32_t cv = 4;
qb.insert(qb.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4);
uint16_t qsz = (uint16_t)(3 + qb.size());
std::vector<uint8_t> qpkt = {HDR_GC_QUEST_INFO};
qpkt.insert(qpkt.end(), (uint8_t *)&qsz, (uint8_t *)&qsz + 2);
qpkt.insert(qpkt.end(), qb.begin(), qb.end());
qpkt.insert(qpkt.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4);
CHECK(qpkt.size() == static_cast<size_t>(quest_info_packet_size(qpkt[5])),
"quest info test packet matches 40250 flag-driven length");
feed(s, qpkt);
const mtnet::QuestInfo *q = s.world().quest(3);
CHECK(q && q->title == "Kill 10 wolves" && q->counter_name == "Wolves" &&
@@ -1185,6 +1284,16 @@ int main() {
out.back() == SEQUENCE_TABLE[seq], "guild invite answer wire");
}
// --- CG_USE_SKILL: PythonNetworkStreamPhaseGame.cpp SendUseSkillPacket sends
// only the use-skill packet; fly-targeting is a separate caller decision ---
{
uint32_t seq = s.stream().sequence_index();
CHECK(s.send_use_skill(3, 8888), "use skill send");
auto out = drain(s);
CHECK(out.size() == sizeof(CGUseSkill) + 1 && out[0] == HDR_CG_USE_SKILL &&
out.back() == SEQUENCE_TABLE[seq], "use skill wire: no extra fly targeting");
}
// --- GC_CHAR_ADDITIONAL_INFO(136) with no matching pending GC_CHARACTER_ADD
// is dropped and leaves the live entity untouched (§2.2) ---
{
@@ -5,6 +5,7 @@
#include <cstdio>
#include <cstring>
#include <string>
#include <utility>
#include <vector>
using namespace mtnet::classic;
@@ -248,6 +249,46 @@ int main() {
CHECK(s.outgoing_pending() == 0, "unknown header does not resync into trailing bytes");
}
// ------------------------------------------- GC_GUILD SKILL_INFO short write
{
// guild.cpp CGuild::SendSkillInfoPacket: size = 4 + 6 + GUILD_SKILL_COUNT (22)
// but only skill_point + abySkill[12] + power(2) + max_power(2) follow the
// head (21 bytes). LoginMember sends it right before SendEnemyGuild's
// TPacketGCGuildName (size 20 = 0x14), which is how a real login desynced
// into "unknown GC header 200 (last: 75,20)".
ClassicStream s;
std::string err;
s.on_error = [&](const std::string &e) { err = e; };
std::vector<std::pair<uint8_t, std::vector<uint8_t>>> got;
s.on_packet = [&](uint8_t h, const uint8_t *body, uint32_t len) {
got.push_back({h, std::vector<uint8_t>(body, body + len)});
return true;
};
std::vector<uint8_t> skill = {HDR_GC_GUILD, 22, 0, 12, 3};
for (uint8_t i = 0; i < 12; ++i) {
skill.push_back(static_cast<uint8_t>(i + 1));
}
skill.insert(skill.end(), {0x10, 0x00, 0x20, 0x00}); // power 16, max_power 32
CHECK(skill.size() == 21, "server SKILL_INFO writes 21 bytes");
std::vector<uint8_t> name = {HDR_GC_GUILD, 20, 0, 16, 0x2A, 0, 0, 0};
const char gname[12] = "Wolves";
name.insert(name.end(), gname, gname + sizeof(gname));
CHECK(name.size() == 20, "TPacketGCGuildName is 20 bytes");
std::vector<uint8_t> wire = skill;
wire.insert(wire.end(), name.begin(), name.end());
s.feed(wire.data(), wire.size());
CHECK(err.empty(), "guild skill info + guild name: no framing error");
CHECK(got.size() == 2, "guild skill info + guild name: two packets delivered");
if (got.size() == 2) {
CHECK(got[0].second.size() == 1 + 17 && got[0].second[0] == 12 &&
got[0].second[1] == 3 && got[0].second[16] == 0x20,
"skill info body = subheader + 17 bytes");
CHECK(got[1].first == HDR_GC_GUILD && got[1].second.size() == 17 &&
got[1].second[0] == 16 && got[1].second[1] == 0x2A,
"guild name packet framed intact after skill info");
}
}
// ---------------------------------------------------------- rejected handler
{
ClassicStream s;
+83
View File
@@ -152,6 +152,89 @@ int main() {
"chk_time past cap: released immediately (staleness valve)");
}
// --- 7) motion-driven remote walk (reference MovementProcess) --------------
// Speed comes from the WALK/RUN root motion * movSpd/100, not dwDuration;
// heading follows Src->Dst while walking and takes bRot on arrival.
{
EntityStore es;
es.set_now(0);
es.mut_spawn(70, 101, 2, "Wolf", 0, 0, 0, 0, 100 /*moving_speed*/, 0);
es.set_motion_speed(70, 87.5f, 425.0f);
es.tick();
// server says 100 ms (it prices NPC walks at RUN speed); ignored here.
es.mut_move(70, 45.0f, FUNC_WAIT, 1000.0f, 0.0f, 100, 0, 0);
CHECK(es.get(70)->moving && std::abs(es.get(70)->angle - 90.0f) < 1e-3f,
"motion walk: faces Src->Dst, not bRot");
uint32_t now = 0;
for (int i = 0; i < 10; ++i) {
now += 100;
es.set_now(now);
es.tick();
}
CHECK(es.get(70)->moving && std::abs(es.get(70)->x - 425.0f) < 1.0f,
"motion walk: run mode advances at RUN motion speed, ignores dwDuration");
es.mut_walk_mode(70, WALKMODE_WALK);
for (int i = 0; i < 10; ++i) {
now += 100;
es.set_now(now);
es.tick();
}
CHECK(std::abs(es.get(70)->x - 512.5f) < 1.0f, "motion walk: walk mode uses WALK motion speed");
es.mut_change_speed(70, 200); // movSpd 200 doubles the step
now += 1000;
es.set_now(now);
es.tick(); // capped at MOVE_MAX_TICK_MS
CHECK(std::abs(es.get(70)->x - (512.5f + 175.0f * 0.25f)) < 1.0f,
"motion walk: movSpd scales speed; tick step capped");
for (int i = 0; i < 100 && es.get(70)->moving; ++i) {
now += 100;
es.set_now(now);
es.tick();
}
CHECK(!es.get(70)->moving && es.get(70)->func == FUNC_WAIT, "motion walk: stops on arrival");
CHECK(es.get(70)->x == 1000.0f && es.get(70)->y == 0.0f, "motion walk: snaps to Dst");
CHECK(std::abs(es.get(70)->angle - 45.0f) < 1e-3f, "motion walk: bRot applied on arrival");
}
// --- 8) FUNC_MOVE walks past Dst, overshoot guard turns back and stops ------
{
EntityStore es;
es.set_now(0);
es.mut_spawn(80, 0, 0, "Pc", 0, 0, 0, 0, 100, 0);
es.set_motion_speed(80, 0.0f, 1000.0f);
es.tick();
es.mut_move(80, 90.0f, FUNC_MOVE, 100.0f, 0.0f, 100, 0, 0);
uint32_t now = 0;
for (int i = 0; i < 3; ++i) {
now += 50;
es.set_now(now);
es.tick();
}
CHECK(es.get(80)->moving && es.get(80)->x > 100.0f, "FUNC_MOVE: keeps walking past Dst");
bool turned = false;
for (int i = 0; i < 40 && es.get(80)->moving; ++i) {
now += 50;
es.set_now(now);
es.tick();
turned = turned || std::abs(es.get(80)->angle - 270.0f) < 1e-3f;
}
CHECK(turned, "FUNC_MOVE overshoot: turns back toward Dst");
CHECK(!es.get(80)->moving && es.get(80)->x == 100.0f && es.get(80)->func == FUNC_WAIT,
"FUNC_MOVE overshoot: returns to Dst and stops");
}
// --- 9) unknown motion speed keeps the dwDuration lerp, heading Src->Dst -----
{
EntityStore es;
es.set_now(0);
spawn(es, 90, 0, 0);
es.mut_move(90, 0.0f, FUNC_WAIT, 0.0f, -500.0f, 1000, 0, 0);
CHECK(std::abs(es.get(90)->angle - 180.0f) < 1e-3f, "duration walk: faces Src->Dst");
es.set_now(500);
es.tick();
CHECK(std::abs(es.get(90)->y + 250.0f) < 1e-3f, "duration walk: lerps over dwDuration");
}
if (g_fail == 0) {
std::printf("PASS: net_state_queue_test (§3.2 TCP state queue / thresholds / gates)\n");
}
+24 -5
View File
@@ -51,7 +51,13 @@ func _init() -> void:
func start(assets_root: String = "", injected_client: Node = null) -> void:
_assets = assets_root
serverinfo = ServerInfoRes.new()
serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置
# 联网内测的地址覆盖只在 MT_TEST_MODE=playable 生效;父运行器把同一路径交给
# 预检,保证预检目标与客户端真实连接目标来自同一个 ServerInfo 解析。
var test_serverlist := OS.get_environment("MT_PLAYABLE_SERVERLIST").strip_edges()
if OS.get_environment("MT_TEST_MODE") == "playable" and not test_serverlist.is_empty():
serverinfo.load_file(test_serverlist)
else:
serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置
if injected_client != null:
client = injected_client
elif ClassDB.class_exists("M2Client"):
@@ -84,6 +90,17 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
func state() -> int:
return _state
# Playable-test only: choose the server/channel before the deferred autologin
# connect runs, and return the exact address _do_connect() will use.
func select_test_server(server_index: int, channel: int) -> Dictionary:
if OS.get_environment("MT_TEST_MODE") != "playable" or serverinfo == null:
return {}
if serverinfo.server(server_index).is_empty():
return {}
_sel_server = server_index
_sel_channel = channel
return serverinfo.address(_sel_server, _sel_channel)
# Read-only adapter for the packaged playable test. It deliberately returns
# summaries rather than the private UI/game nodes so a report cannot retain a
# stale scene reference across reconnect or map reload.
@@ -147,14 +164,16 @@ func _on_char_list(list: Array) -> void:
if auto_login and not _chars.is_empty():
var idx := int(_chars[0].get("index", 0))
if _auto_char_slot >= 0:
# An explicit MT_CHAR_SLOT that the account lacks must not silently enter
# another character; stay on the select screen instead.
idx = -1
for character in _chars:
if int(character.get("index", -1)) == _auto_char_slot:
idx = _auto_char_slot
break
if client.has_method("enter_game"):
client.enter_game(idx)
elif client.has_method("select_character"):
client.select_character(idx)
if idx >= 0:
# Remember the slot so a later in-game reconnect re-enters the same character.
_enter_character(idx)
func _on_char_name_changed(pid: int, name: String) -> void:
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
+14
View File
@@ -74,6 +74,20 @@ func _init() -> void:
_ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true),
"playable snapshot reports GAME after reconnect")
# Auto-login with an explicit MT_CHAR_SLOT must never fall back to another
# character, and the chosen slot must be remembered for a later reconnect.
flow._state = AppFlow.LOGIN
flow.auto_login = true
flow._auto_char_slot = 5
flow._selected_char_slot = -1
client.selects.clear()
client.char_list.emit([{"index": 0, "name": "Other"}])
_ck(client.selects.is_empty(), "absent explicit slot does not enter chars[0]")
flow._state = AppFlow.LOGIN
client.char_list.emit([{"index": 0, "name": "Other"}, {"index": 5, "name": "Fixture"}])
_ck(client.selects == [5] and flow._selected_char_slot == 5,
"explicit slot is entered and remembered for reconnect")
flow.queue_free()
await process_frame
if _fail == 0:
+303 -34
View File
@@ -19,7 +19,9 @@ class FakeClient extends Node:
signal affect_added(affect: Dictionary)
signal affect_removed(type: int)
signal phase_changed(phase: String)
signal combo_changed(enabled: bool)
var main := 1000
var skills := []
var ents := {1000: {"vid": 1000, "hp": 100, "dead": false}, 2000: {"vid": 2000, "hp": 50, "dead": false, "pos": Vector3(1, 0, 0)}}
var attacks := []
var moves := []
@@ -40,10 +42,38 @@ class FakeClient extends Node:
func move(a, b, c, d, e) -> bool: moves.append([a, b, c, d, e]); return true
func sync_positions(arr) -> bool: synced.append(arr); return true
func say(t, s) -> bool: said.append([t, s]); return true
func get_skills() -> Array: return skills
class FakeNetWorld extends Node:
var nodes := {}
var pushes := [] # [vid, dir(actor-world xy), force]
var pushing := {}
func node_for(vid: int) -> Node3D: return nodes.get(vid, null)
func push_victim(vid: int, dir: Vector2, force: float) -> void:
pushes.append([vid, dir, force])
pushing[vid] = true
func is_pushing(vid: int) -> bool: return bool(pushing.get(vid, false))
func blending_position(vid: int) -> Vector3: return nodes[vid].global_position + Vector3(0.1, 0, 0)
# 受击方视图桩:.msm 防御球 + InsertDelay + __Hit* 反应
class FakeVictim extends Node3D:
var spheres := [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}]
var delays := []
var reactions := []
func get_defending_spheres() -> Array: return spheres
func insert_delay(t: float) -> void: delays.append(t)
func hit_good(scalar: float, _stunned: bool) -> void: reactions.append(["good", scalar])
func hit_greate(scalar: float, _stunned: bool) -> void: reactions.append(["greate", scalar])
func hit_stone(_stunned: bool) -> void: reactions.append(["stone", 0.0])
class FakeFx extends Node:
var spawned := [] # [name, global_pos, node]
func spawn_at(n: String, parent: Node3D, pos: Vector3, _one_shot := true) -> Node3D:
var e := Node3D.new()
parent.add_child(e)
e.global_position = pos
spawned.append([n, pos, e])
return e
class FakeProto extends Node:
var items := {}
@@ -61,11 +91,18 @@ class FakeAnim extends Node:
func get_motion_data() -> Dictionary: return md
class FakeView extends Node3D:
signal motion_bound(state: String)
var states := []
var attack_motions := [] # [motion_mode, motion_index, speed_ratio]
var delays := []
var anim := FakeAnim.new()
func _init() -> void:
add_child(anim)
func set_anim_state(s): states.append(s)
func play_attack_motion(mode: int, index: int, speed: float) -> void:
attack_motions.append([mode, index, speed])
motion_bound.emit("attack")
func insert_delay(t: float) -> void: delays.append(t)
class FakeHud extends Node:
var affects := []
@@ -77,6 +114,19 @@ func _ck(c: bool, m: String) -> void:
_fail += 1
printerr("FAIL: " + m)
# 命中小节:每个场景前清掉命中表 / 无敌时间 / 防御球上一帧缓存 / 硬直与各桩记录
func _reset_hit(np: Node, fc: Node, nw: Node, fx: Node) -> void:
np._hit_dedup.clear()
np._victim_invisible_until.clear()
np._def_last.clear()
np._delay = 0.0
np._victim_flush.clear()
fc.attacks.clear()
fc.synced.clear()
nw.pushes.clear()
nw.pushing.clear()
fx.spawned.clear()
func _init() -> void:
await _run()
if _fail == 0:
@@ -156,8 +206,49 @@ func _run() -> void:
_ck(is_equal_approx(np._atk_speed_factor, 1.5), "attack_speed 150 -> factor 1.5")
_ck(is_equal_approx(np._current_attack_period(), np.DEFAULT_ATTACK_PERIOD / 1.5),
"attack speed scales the resolved period")
# CInstanceBase::SetAttackSpeeduAtkSpd > 1100 -> 0;否则 /100,无上下限
np._apply_attack_speed(400)
_ck(is_equal_approx(np._atk_speed_factor, 4.0), "attack_speed 400 -> factor 4.0 (no clamp)")
np._apply_attack_speed(1200)
_ck(np._atk_speed_factor == 0.0, "attack_speed > 1100 -> factor 0")
_ck(is_finite(np._current_attack_period()), "factor 0 -> attack period stays finite")
np._apply_attack_speed(100)
# --- CGraphicThingInstance::InsertDelay / UpdateTime:本地时间 ---
np._local_time = 5.0
np._delay = 0.05
np._advance_local_time(0.02)
_ck(is_equal_approx(np._local_time, 5.0) and is_equal_approx(np._delay, 0.03),
"delay > elapsed -> local time frozen, delay shrinks")
np._advance_local_time(0.05)
_ck(is_equal_approx(np._local_time, 5.02) and np._delay == 0.0,
"delay < elapsed -> local time advances by the remainder")
np._apply_attack_speed(200)
np._motion_start_t = np._local_time - 0.1
_ck(is_equal_approx(np._attacking_elapsed(), 0.2),
"GetAttackingElapsedTime = (local - start) * fSpeedRatio")
np._apply_attack_speed(100)
var lt0: float = np._local_time
np._process(0.016)
_ck(np._local_time > lt0, "_process advances the local time")
# --- SetComboSkillFlagPythonPlayerSkill.cpp):GC 连击开关 -> SetComboType(MIN(lv,2)) ---
fc.skills = [{"id": 122, "level": 3, "master": 0}]
fc.combo_changed.emit(true)
_ck(np._combo_type == 2, "combo on + skill 122 lv3 -> combo type MIN(3,2)=2, got %d" % np._combo_type)
fc.combo_changed.emit(false)
_ck(np._combo_type == 0, "combo off -> combo type 0")
fc.skills = [{"id": 122, "level": 1, "master": 0}]
fc.combo_changed.emit(true)
_ck(np._combo_type == 1, "combo on + skill 122 lv1 -> combo type 1")
fc.skills = [{"id": 122, "level": 0, "master": 0}]
fc.combo_changed.emit(false)
_ck(np._combo_type == 1, "skill 122 level 0 -> return before SetComboType")
fc.skills = []
fc.combo_changed.emit(false)
_ck(np._combo_type == 1, "no skill 122 slot -> return before SetComboType")
np._combo_type = 0
# --- §3.5 连击状态机:InputComboAttackCommand / __RunNextCombo / ComboProcess ---
# 合成一张 4 段的 1H type1 段表(段号 14..17),并给 player_view 一个可控 .msa。
var K: int = (np.MOTION_MODE_GENERAL << 16) | 0
@@ -181,93 +272,271 @@ func _run() -> void:
_ck(fc.attacks.size() == 1 and fc.attacks[0][0] == 0, "CG_ATTACK bType is skill 0, not combo seg")
_ck(fc.moves.size() >= 1 and fc.moves.back()[0] == np.FUNC_COMBO and fc.moves.back()[1] == 14,
"FUNC_COMBO carries seg no. 14 (NAME_COMBO_ATTACK_1)")
_ck(view.attack_motions.size() == 1 and view.attack_motions[0] == [np.MOTION_MODE_GENERAL, 14, 1.0],
"swing binds the seg motion (mode, index 14, speed ratio 1.0), got %s" % [view.attack_motions])
# 立刻再输入(elapsed ~0 < pre_input_time):既不推进也不置 pre-input
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1 and not np._is_pre_input, "input before InputStartTime -> ignored")
# elapsed 落在 [start, next):置 m_isPreInput,不推进
np._combo_started_t = np._now() - 0.15
# elapsed 落在 [start, next):置 m_isPreInput,不推进GetAttackingElapsedTime 基于本地时间)
np._motion_start_t = np._local_time - 0.15
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 1 and np._is_pre_input, "input in [start,next) -> pre-input latched")
# ComboProcesselapsed 过了 NextComboTime -> 触发挂起的 pre-input -> 段 2
np._combo_started_t = np._now() - 0.25
np._motion_start_t = np._local_time - 0.25
np._combo_process()
_ck(np._combo_index == 2 and not np._is_pre_input, "ComboProcess fires pre-input -> combo index 2")
# elapsed 过 NextComboTime 直接输入 -> 立即推进(段 3、段 4)
np._combo_started_t = np._now() - 0.30
np._motion_start_t = np._local_time - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 3, "input past NextComboTime -> advance to 3")
np._combo_started_t = np._now() - 0.30
np._motion_start_t = np._local_time - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 4, "advance to last seg 4")
# 段 4 是最后一段:__OnEndCombo(非骑乘不复位),再输入越界不推进
np._combo_started_t = np._now() - 0.30
np._motion_start_t = np._local_time - 0.30
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 4, "past last seg -> no further advance (non-horse)")
# 动作回到 Waitelapsed > duration-> __ClearCombo
np._combo_started_t = np._now() - 1.5
# 参考端 ComboProcess 没有「超过整段时长就清连击」:elapsed > duration 段号保持
np._motion_start_t = np._local_time - 1.5
np._combo_process()
_ck(np._combo_index == 0 and not np._is_pre_input, "motion back to Wait -> __ClearCombo")
_ck(np._combo_index == 4, "ComboProcess has no duration timeout -> combo index kept, got %d" % np._combo_index)
# __SetMotion 尾:新绑定的动作没有 ComboInputData(回 Wait-> m_dwcurComboIndex = 0
view.anim.md = {"duration": 2.0}
view.motion_bound.emit("wait")
_ck(np._combo_index == 0, "bound motion without ComboInputData -> combo index 0")
# __SetMotion 尾:绑定带 MotionAttackData 的动作 -> m_HitDataMap.clear()
np._hit_dedup = {0: {2000: 1.0}}
view.anim.md = {"has_attacking_data": true, "has_combo_input": true, "duration": 1.0}
view.motion_bound.emit("attack")
_ck(np._hit_dedup.is_empty(), "bound attacking motion -> hit data map cleared")
# 没有段表(缺资源)-> 退化为单段普攻(段号 13)
# 没有段表(缺资源)-> 退化为单段普攻(段号 13);攻速系数原样传给动作播放
np._combo_tables = {}
fc.moves.clear()
np._apply_attack_speed(150)
np._do_attack_swing(pcn, {})
_ck(np._combo_index == 0 and fc.moves.back()[0] == np.FUNC_COMBO \
and fc.moves.back()[1] == np.NAME_NORMAL_ATTACK,
"no combo table -> single NORMAL_ATTACK swing")
_ck(view.attack_motions.back() == [np.MOTION_MODE_GENERAL, np.NAME_NORMAL_ATTACK, 1.5],
"attack motion plays at fSpeedRatio 1.5, got %s" % [view.attack_motions.back()])
np._apply_attack_speed(100)
# --- §3.5 修改 4:命中窗几何判定 -> OnHit + FlushVictimList ---
# --- __NormalAttackProcess 的 m_HitDataMap:新窗 insert;旧窗 map::insert 不覆盖 + 每窗上限 ---
np._hit_dedup.clear()
np._hit_motion_type = np.MOTION_TYPE_SKILL
np._hit_limit_count = 2
np._hit_invisible_time = 0.5
np._local_time = 10.0
_ck(np._register_hit(0, 1) and is_equal_approx(float(np._hit_dedup[0][1]), 10.5),
"first hit of a window -> insert local + fInvisibleTime, process")
np._local_time = 11.0
_ck(np._register_hit(0, 1) and is_equal_approx(float(np._hit_dedup[0][1]), 10.5),
"victim already in the window -> map::insert keeps the old time, count 1 <= limit")
_ck(np._register_hit(0, 2), "second victim -> count 2 <= iHitLimitCount 2")
_ck(not np._register_hit(0, 3), "third victim -> count 3 > iHitLimitCount -> FALSE")
np._hit_limit_count = 0
np._hit_dedup.clear()
_ck(np._register_hit(0, 1), "SKILL: first hit of a window skips the limit check")
_ck(not np._register_hit(0, 1), "SKILL iHitLimitCount 0 -> next hit in that window 1 > 0 -> FALSE")
np._hit_motion_type = np.MOTION_TYPE_COMBO
for i in 16:
np._register_hit(1, 100 + i)
_ck(not np._register_hit(1, 200), "COMBO/NORMAL: 17th victim in one window -> > 16 -> FALSE")
np._hit_dedup.clear()
# --- §3.5 修改 4 / §3.7__NormalAttackProcess 扫掠球 vs .msm 防御球 -> __ProcessDataAttackSuccess ---
# SceneTree 脚本里 add_child 到 root 的节点要等一帧才真正 is_inside_tree()
# 几何判定读 global_position,所以这一小节先 await 一帧。
var nw := FakeNetWorld.new(); get_root().add_child(nw)
np.net_world = nw
var vnode := Node3D.new(); get_root().add_child(vnode)
var fx := FakeFx.new(); get_root().add_child(fx)
var fx_parent := Node3D.new(); get_root().add_child(fx_parent)
np.fx = fx
np.fx_parent = fx_parent
var vnode := FakeVictim.new(); get_root().add_child(vnode)
nw.nodes[2000] = vnode
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "hp": 50, "dead": false, "knock_down": true}
fc.ents[2000] = {"vid": 2000, "ch_type": 2, "race": 101, "hp": 50, "dead": false, "owner_vid": 1000}
fc.ents[1000]["dead"] = false
await process_frame
vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 myaw 0 -> 前向 +Z
vnode.global_position = Vector3(0, 0, 1.0) # 攻击者正前方 1 myaw 0:模型 -Y = Godot +Z
vnode.rotation.y = 0.0
pcn.global_position = Vector3.ZERO
pcn.rotation.y = 0.0
# 一个采样:t=0.1 时刀尖球从模型本地 (-60,-80) 横扫到 (60,-80),高 1 m
view.anim.md = {
"has_attacking_data": true,
"hit_windows": [{
"start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand",
"weapon_length": 120.0, "samples": [],
"start_time": 0.0, "end_time": 0.5, "bone": "equip_right_hand", "weapon_length": 120.0,
"samples": [{"time": 0.1, "last_pos": Vector3(-60, -80, 100), "pos": Vector3(60, -80, 100)}],
}],
"motion_type": np.MOTION_TYPE_COMBO, "hit_limit_count": 0, "invisible_time": 0.1,
"next_combo": 0.2,
"motion_type": np.MOTION_TYPE_COMBO, "hitting_type": np.HIT_TYPE_GOOD, "hit_limit_count": 0,
"invisible_time": 0.1, "stiffen_time": 0.05, "external_force": 3.0, "next_combo": 0.2,
}
np._combo_tables = {}
fc.attacks.clear()
fc.synced.clear()
np._target_vid = 0
view.delays.clear()
_reset_hit(np, fc, nw, fx)
np._do_attack_swing(pcn, {}) # _emit_swing:有命中窗 -> CG_ATTACK 不在挥击时发
_ck(fc.attacks.size() == 0, "hit windows present -> CG_ATTACK deferred off the swing")
_ck(np._hit_windows.size() == 1, "swing cached the .msa hit window")
np._swing_start_t = np._now() - 0.1 # 推进到命中窗内
np._process(0.016)
_ck(fc.attacks.size() == 1 and fc.attacks[0] == [0, 2000],
"in-window + in front arc -> OnHit CG_ATTACK(skill 0, vid 2000)")
_ck(fc.synced.size() == 1 and fc.synced[0][0]["vid"] == 2000,
"pushed victim -> frame-end CG_SYNC_POSITION")
np._swing_start_t = np._now() - 0.2
np._process(0.016)
# 采样时刻 0.1 不在 [motiontime - elapsed, motiontime] 内 -> 不判定
np._motion_start_t = np._local_time - 0.2
np._attack_process(0.016)
_ck(fc.attacks.is_empty(), "no .msa sample inside [t-dt, t] -> no hit")
np._motion_start_t = np._local_time - 0.105
np._attack_process(0.016)
_ck(fc.attacks == [[0, 2000]], "sweep sphere crosses the defending sphere -> OnHit CG_ATTACK(0, 2000), got %s" % [fc.attacks])
_ck(np._target_vid == 2000, "OnHit -> SetTarget(victim)")
_ck(is_equal_approx(np._delay, 0.05) and view.delays == [0.05], "attacker InsertDelay(fStiffenTime), got %s" % [view.delays])
_ck(vnode.delays == [0.05], "victim InsertDelay(fStiffenTime), got %s" % [vnode.delays])
_ck(float(np._victim_invisible_until.get(2000, 0.0)) > np._now(), "victim m_fInvisibleTime = now + fInvisibleTime")
_ck(vnode.reactions == [["good", 1.0]], "HIT_TYPE_GOOD -> __HitGood(scalar cos(0) = 1), got %s" % [vnode.reactions])
_ck(fx.spawned.size() == 1 and fx.spawned[0][0] == np.EFFECT_HIT \
and Vector3(fx.spawned[0][1]).is_equal_approx(vnode.global_position),
"blow_1_low.mse spawned at the victim position, got %s" % [fx.spawned])
if fx.spawned.size() == 1:
_ck(absf(absf((fx.spawned[0][2] as Node3D).rotation.y) - PI) < 0.01, "hit effect yaw = atan2(-dx, -dz) = ±π")
_ck(nw.pushes.size() == 1 and nw.pushes[0][0] == 2000 and Vector2(nw.pushes[0][1]).is_equal_approx(Vector2(0, -1)) \
and is_equal_approx(float(nw.pushes[0][2]), 3.0),
"__PushCircle + IncreaseExternalForce(3) along victim - attacker (actor-world (0,-1)), got %s" % [nw.pushes])
np._flush_victim_list()
var bxy := MapCoord.to_server_cm(vnode.global_position + Vector3(0.1, 0, 0))
_ck(fc.synced.size() == 1 and fc.synced[0][0]["vid"] == 2000 and fc.synced[0][0]["x"] == int(bxy.x) \
and fc.synced[0][0]["y"] == int(bxy.y),
"pushing victim -> frame-end CG_SYNC_POSITION at its blending position, got %s" % [fc.synced])
# COMBO:同窗同目标只一次(先把受击方无敌时间去掉,单独验 m_HitDataMap
np._victim_invisible_until.clear()
np._attack_process(0.016)
_ck(fc.attacks.size() == 1, "COMBO motion_type -> same window/victim hits once only")
vnode.global_position = Vector3(0, 0, -1.0) # 转到身后
# AttackingProcessrVictim.__isInvisible() -> FALSE
np._hit_dedup.clear()
np._swing_start_t = np._now() - 0.2
np._process(0.016)
_ck(fc.attacks.size() == 1, "victim behind attacker -> outside front arc -> no hit")
np._def_last.clear()
np._victim_invisible_until[2000] = np._now() + 5.0
np._attack_process(0.016)
_ck(fc.attacks.size() == 1, "victim still inside m_fInvisibleTime -> no hit")
# __CanPushDestActorowner 不是攻击者 / owner 超 3 秒 -> 不推;晕眩 -> 推
_reset_hit(np, fc, nw, fx)
fc.ents[2000]["owner_vid"] = 0
np._attack_process(0.016)
np._flush_victim_list()
_ck(fc.attacks.size() == 1 and nw.pushes.is_empty() and fc.synced.is_empty(),
"victim not owned by the attacker -> hit, no push, no CG_SYNC_POSITION")
_reset_hit(np, fc, nw, fx)
fc.ents[2000]["owner_vid"] = 1000
np._owner_seen[2000] = [1000, np._now() - 3.5]
np._attack_process(0.016)
_ck(fc.attacks.size() == 1 and nw.pushes.is_empty(), "owner time > 3 s -> no push")
_reset_hit(np, fc, nw, fx)
fc.ents[2000]["stunned"] = true
np._attack_process(0.016)
_ck(nw.pushes.size() == 1, "stunned victim -> always pushable")
fc.ents[2000]["stunned"] = false
np._owner_seen.erase(2000)
# HIT_TYPE_NONE__ProcessDataAttackSuccess 直接 return(不硬直、不发包);GREAT -> __HitGreate
_reset_hit(np, fc, nw, fx)
view.delays.clear()
vnode.delays.clear()
vnode.reactions.clear()
np._hit_type = np.HIT_TYPE_NONE
np._attack_process(0.016)
_ck(fc.attacks.is_empty() and view.delays.is_empty() and vnode.delays.is_empty() and fx.spawned.is_empty(),
"HIT_TYPE_NONE -> no delay / effect / OnHit")
_reset_hit(np, fc, nw, fx)
np._hit_type = np.HIT_TYPE_GREAT
np._attack_process(0.016)
_ck(vnode.reactions.size() == 1 and vnode.reactions[0][0] == "greate", "HIT_TYPE_GREAT -> __HitGreate, got %s" % [vnode.reactions])
np._hit_type = np.HIT_TYPE_GOOD
# 距离怪癖:v3Distance = (dX, dZ, dZ) —— 平面 Y 不参与,高度差算两次
_reset_hit(np, fc, nw, fx)
vnode.global_position = Vector3(3.5, 0, 1.0)
vnode.spheres = [{"radius": 45.0, "pos": Vector3(-350, 0, 100), "bone": ""}]
np._attack_process(0.016)
_ck(fc.attacks.is_empty(), "|dX| 350 >= 300 -> distance reject although the spheres touch")
_reset_hit(np, fc, nw, fx)
vnode.global_position = Vector3(0, 0, 3.5)
vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 270, 100), "bone": ""}]
np._attack_process(0.016)
_ck(fc.attacks.size() == 1, "|dY| 350 is not part of the distance check -> hit")
_reset_hit(np, fc, nw, fx)
vnode.global_position = Vector3(0, 2.2, 1.0)
vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}]
np._attack_process(0.016)
_ck(fc.attacks.is_empty(), "height 2.2 m -> 2 * 220² >= 300² -> reject")
_reset_hit(np, fc, nw, fx)
vnode.global_position = Vector3(0, 2.0, 1.0)
np._attack_process(0.016)
_ck(fc.attacks.size() == 1, "height 2.0 m -> 2 * 200² < 300², Z cylinder ignores height -> hit")
# IS_HUGE_RACE500 cm 距离、不推、特效在 (攻击者 + 防御球) * 0.5
_reset_hit(np, fc, nw, fx)
fc.ents[2000]["race"] = 2493
vnode.global_position = Vector3(4.0, 0, 1.0)
vnode.spheres = [{"radius": 45.0, "pos": Vector3(-400, 0, 100), "bone": ""}]
np._attack_process(0.016)
np._flush_victim_list()
_ck(fc.attacks.size() == 1 and nw.pushes.is_empty() and fc.synced.is_empty(),
"IS_HUGE_RACE: 400 cm < 500 -> hit, no push, no sync")
_ck(fx.spawned.size() == 1 and Vector3(fx.spawned[0][1]).is_equal_approx(Vector3(0, 0.5, 0.5)),
"IS_HUGE_RACE: effect at (attacker + defending sphere) * 0.5, got %s" % [fx.spawned])
fc.ents[2000]["race"] = 101
# 防御球随受击方朝向旋转;__HitGood 的 scalar = cos(攻击者朝向 - 受击方朝向)
_reset_hit(np, fc, nw, fx)
vnode.reactions.clear()
vnode.global_position = Vector3(0, 0, 1.6)
vnode.spheres = [{"radius": 25.0, "pos": Vector3(0, -60, 100), "bone": ""}]
vnode.rotation.y = 0.0
np._attack_process(0.016)
_ck(fc.attacks.is_empty(), "victim yaw 0 -> defending sphere 220 cm ahead -> miss")
_reset_hit(np, fc, nw, fx)
vnode.rotation.y = PI
np._attack_process(0.016)
_ck(fc.attacks.size() == 1, "victim yaw π -> sphere swings to 100 cm -> hit")
_ck(vnode.reactions.size() == 1 and is_equal_approx(float(vnode.reactions[0][1]), -1.0),
"__HitGood scalar = cos(0 - π) = -1, got %s" % [vnode.reactions])
vnode.rotation.y = 0.0
# 身后:扫掠球够不到
_reset_hit(np, fc, nw, fx)
vnode.global_position = Vector3(0, 0, -1.0)
vnode.spheres = [{"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}]
np._attack_process(0.016)
_ck(fc.attacks.is_empty(), "victim behind the attacker -> sweep misses")
# 占位胶囊(无 .msm):退化防御球
var ph := Node3D.new(); get_root().add_child(ph)
await process_frame
ph.global_position = Vector3(0, 0, 1.0)
nw.nodes[2000] = ph
_reset_hit(np, fc, nw, fx)
np._attack_process(0.016)
_ck(fc.attacks.size() == 1, "node without defending spheres -> fallback sphere -> hit")
nw.nodes[2000] = vnode
vnode.global_position = Vector3(0, 0, 1.0)
np._swing_start_t = np._now() - 2.0 # 动作越过所有命中窗
np._process(0.016)
np._motion_start_t = np._local_time - 2.0 # 动作越过所有命中窗
np._attack_process(0.016)
_ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared")
# 目标已死:挥击命中窗内也不发 CG_ATTACK(死亡目标不是有效命中)
fc.ents[2000]["dead"] = true
np._do_attack_swing(pcn, {})
_reset_hit(np, fc, nw, fx)
np._motion_start_t = np._local_time - 0.105
np._attack_process(0.016)
_ck(fc.attacks.is_empty(), "dead victim inside the hit window -> no CG_ATTACK")
fc.ents[2000]["dead"] = false
np.net_world = null
np.fx = null
# --- §3.5 修改 6:武器种类 -> combo_motion_modeRefreshState 的 SetMotionMode 分支)---
# 纯映射表:CItemData 类型/子类型 + 骑乘/变身 -> CRaceMotionData::EMode
+41
View File
@@ -1,6 +1,7 @@
extends SceneTree
const EffectPlayer = preload("res://fx/effect_player.gd")
const EffectRegistry = preload("res://fx/effect_registry.gd")
var failures := 0
func check(ok: bool, label: String) -> void:
@@ -84,5 +85,45 @@ func run() -> void:
check(is_equal_approx(live._particle_states[0].clock, stopped_clock),
"SceneTree leaves stopped clock unchanged")
live.free()
await registry_lifecycle()
print("effect_playback_test: failures=%d" % failures)
quit(1 if failures else 0)
# CBT-01 evidence: EffectRegistry reports the real spawn and tree-exit boundaries.
func registry_lifecycle() -> void:
var registry := EffectRegistry.new()
var spec := {"lights": [{"duration": 0.8, "diffuse": [1.0, 0.5, 0.2, 1.0]}]}
registry._path_cache["synthetic"] = "synthetic.mse"
registry._spec_cache["synthetic.mse"] = spec
var spawned: Array = []
var finished: Array = []
registry.fx_spawned.connect(func(effect: String, id: int, lifetime: int) -> void: spawned.append([effect, id, lifetime]))
registry.fx_finished.connect(func(effect: String, id: int, lifetime: int, elapsed: int, reason: String) -> void:
finished.append([effect, id, lifetime, elapsed, reason]))
var parent := Node3D.new()
root.add_child(parent)
var once := registry.spawn("synthetic", parent, true)
check(spawned.size() == 1 and spawned[0][0] == "synthetic" and spawned[0][2] == 1000,
"one-shot spawn reports its MSE-defined lifetime: %s" % [spawned])
check(finished.is_empty(), "spawn alone is not a finish")
await create_timer(1.8).timeout
check(not is_instance_valid(once), "one-shot effect left the tree on its own clock")
check(finished.size() == 1 and finished[0][1] == spawned[0][1] and finished[0][4] == "cleanup",
"tree exit reports the same id with reason cleanup: %s" % [finished])
if finished.size() == 1:
check(int(finished[0][3]) >= 1000 and int(finished[0][3]) <= 1000 + 1000,
"cleanup happened inside lifetime + 1s (elapsed %dms)" % int(finished[0][3]))
var looping := registry.spawn("synthetic", parent, false)
check(spawned.size() == 2 and spawned[1][2] == -1 and spawned[1][1] != spawned[0][1],
"looping spawn has a fresh id and no defined lifetime")
var early := registry.spawn("synthetic", parent, true)
parent.remove_child(early)
early.free()
check(finished.size() == 2 and finished[1][4] == "removed", "owner removal before cleanup reports reason removed")
parent.free()
check(finished.size() == 3 and finished[2][1] == spawned[1][1], "scene teardown closes the looping effect")
var orphan := Node3D.new()
check(registry.spawn("missing", orphan, true) == null and spawned.size() == 3,
"unresolvable effect emits no spawn event")
orphan.free()
await process_frame
+60 -11
View File
@@ -15,7 +15,8 @@ class FakeClient extends Node:
for i in 11: equip.append({"vnum": 0, "count": 0, "wear": i})
func get_equipment() -> Array: return equip
func get_main_vid() -> int: return 1000
func get_entity(_v) -> Dictionary: return {"race": 0, "parts": main_parts}
var mount_vnum := 0
func get_entity(_v) -> Dictionary: return {"race": 0, "parts": main_parts, "mount_vnum": mount_vnum}
func set_wear(idx, vnum):
equip[idx] = {"vnum": vnum, "count": 1, "wear": idx}
inventory_changed.emit(2, 90 + idx) # window EQUIPMENT
@@ -36,6 +37,14 @@ class StubModel extends Node3D:
"hair_skin": hair_skin = val; return true
return false
# item_proto 桩:只给武器 subtypeWEAPON_DAGGER=1 BOW=2 FAN=5item_length.h EWeaponSubTypes
class FakeProto extends Node:
const SUB := {19: 0, 2000: 2, 4000: 1, 7000: 5}
func item(vnum: int) -> Dictionary:
if SUB.has(vnum):
return {"type": 1, "sub_type": SUB[vnum]}
return {}
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
@@ -43,7 +52,7 @@ func _ck(c: bool, m: String) -> void:
printerr("FAIL: " + m)
func _init() -> void:
_run()
await _run()
if _fail == 0:
print("PASS: equip_model_test (item_list + weapon swap)")
quit(0)
@@ -97,15 +106,50 @@ func _run() -> void:
await process_frame
_ck(model.gr2_path == "d:/ymir work/pc/warrior/warrior.gr2", "armor_model_map -> gr2_path set")
# 盾(WEAR_SHIELD=10-> shield_gr2 = item_list.model 解析
# 用一个真存在的武器 vnum 当盾(资产里 00010.gr2 存在)
fc.set_wear(10, 19)
# 左右手:ActorInstanceAttach.cpp AttachWeapon
# __IsRightHandWeapon: DAGGER / (FAN && 骑马) -> 右;BOW -> 否;其余 -> 右
# __IsLeftHandWeapon : DAGGER / (FAN && 骑马) / BOW -> 左(GetSubModelThing = 同一 gr2
# shield_gr2 槽 = PART_WEAPON_LEFT;参考端不渲染 WEAR_SHIELD(盾在 item_list 没模型)。
var fch := FakeClient.new()
var mh := StubModel.new()
var fp := FakeProto.new()
get_root().add_child(fch)
get_root().add_child(mh)
get_root().add_child(fp)
var emh: Node = EquipModel.new()
get_root().add_child(emh)
emh.setup(fch, il, func() -> Node: return mh, assets)
emh.main_getter = func() -> int: return 1000
emh.proto = fp
var sword_p: String = emh._resolve_weapon(19)
var bow_p: String = emh._resolve_weapon(2000)
var dagger_p: String = emh._resolve_weapon(4000)
var fan_p: String = emh._resolve_weapon(7000)
_ck(sword_p != "" and bow_p != "" and dagger_p != "" and fan_p != "", "weapon gr2 resolvable (19/2000/4000/7000)")
fch.set_wear(4, 19)
await process_frame
print(" shield_gr2 -> '", model.shield_gr2, "'")
_ck(model.shield_gr2 == "" or model.shield_gr2.ends_with("00010.gr2"), "WEAR_SHIELD -> shield_gr2")
fc.set_wear(10, 0)
_ck(mh.weapon_gr2 == sword_p and mh.shield_gr2 == "", "sword -> right hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
fch.set_wear(4, 4000)
await process_frame
_ck(model.shield_gr2 == "", "unequip shield -> shield_gr2 cleared")
_ck(mh.weapon_gr2 == dagger_p and mh.shield_gr2 == dagger_p, "dagger -> both hands (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
fch.set_wear(4, 2000)
await process_frame
_ck(mh.weapon_gr2 == "" and mh.shield_gr2 == bow_p, "bow -> left hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
fch.set_wear(4, 7000)
await process_frame
_ck(mh.weapon_gr2 == fan_p and mh.shield_gr2 == "", "fan on foot -> right hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
fch.mount_vnum = 20030
fch.entity_info.emit(1000, {})
await process_frame
_ck(mh.weapon_gr2 == fan_p and mh.shield_gr2 == fan_p, "fan mounted -> both hands (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
fch.mount_vnum = 0
fch.set_wear(4, 19)
fch.set_wear(10, 19) # WEAR_SHIELD 不渲染
await process_frame
_ck(mh.shield_gr2 == "", "WEAR_SHIELD is not rendered (%s)" % mh.shield_gr2)
fch.set_wear(4, 0)
await process_frame
_ck(mh.weapon_gr2 == "" and mh.shield_gr2 == "", "unequip -> both hands cleared")
# 头盔(WEAR_HEAD=1)有模型 -> 覆盖 hair_gr2;拆下回角色发型(parts[3]=5
var em2: Node = EquipModel.new()
@@ -139,6 +183,11 @@ func _run() -> void:
# 11000 "Wolf Armour" -> values[3] == 0 -> 回退 =vnum
_ck(em3._armor_shape_default(11000) == 11000, "armor 11000 -> values[3]=0 回退 =vnum")
_ck(em3._armor_specular(11209) == 100, "armor 11209(+9) -> specular 100")
# FakeProto 的 subtype 与真 item_proto 一致
for v in FakeProto.SUB:
var it: Dictionary = pr.item(v)
_ck(int(it.get("type", -1)) == 1 and int(it.get("sub_type", -1)) == FakeProto.SUB[v],
"item_proto %d -> WEAPON sub_type %d (got %s/%s)" % [v, FakeProto.SUB[v], it.get("type"), it.get("sub_type")])
# 时装 / parts[] 优先:服务器把 costume vnum 写进主角 parts[ARMOR],压过装备槽
var seen := {"body": -1, "hair": ""}
@@ -174,7 +223,7 @@ func _run() -> void:
fc.equip[10] = {"vnum": 19, "count": 1, "wear": 10} # 有盾
fc.main_parts = [88888, 0, 0, 5] # parts[ARMOR] -> shape 101
model.weapon_gr2 = "x"; model.shield_gr2 = "x"; model.hair_gr2 = "x"
em5._last_weapon_vnum = -1; em5._last_shield_vnum = -1
em5._last_weapon_vnum = -1
em5._last_body_vnum = -1; em5._last_head_vnum = -2
em5.refresh()
_ck(model.weapon_gr2 == "", "动物时装 -> weapon_gr2 清空 (%s)" % model.weapon_gr2)
@@ -182,7 +231,7 @@ func _run() -> void:
_ck(model.hair_gr2 == "", "动物时装 -> hair_gr2 清空 (%s)" % model.hair_gr2)
# 脱下动物时装 -> 武器恢复解析(资产里 00010.gr2 可能不存在,至少不再是遮挡态)
fc.main_parts = [0, 0, 0, 5]
em5._last_weapon_vnum = -999; em5._last_shield_vnum = -999
em5._last_weapon_vnum = -999
em5._last_body_vnum = -1; em5._last_head_vnum = -2
em5.refresh()
_ck(model.weapon_gr2 == "" or model.weapon_gr2.ends_with("00010.gr2"),
+578 -78
View File
@@ -1,96 +1,596 @@
extends Node
## 离线加载实际鬼木林/赤鬼木林地图,并把树怪放进生产 GameScene 的实体挂载点
## 服务器刷新、选中、战斗和掉落仍由 playable_live_test 负责。
## MAP-02 地图内离线验收:MT_TEST_MODE=forest_render(包内与编辑器都经 client_main 进入)
##
## MT_TEST_MODE=forest_render MT_FOREST_MAPS=<map_key>[,<map_key>] godot --path project
## bash script/forest_map_render_test.sh --help # 包内运行、墙钟超时、最终报告
##
## 地图只来自 MT_FOREST_MAPS 或 MT_PLAYABLE_CONFIG 的 map_key(由 audit_playable_maps 的结果填写),
## 不在脚本里猜地图。每张图:生产 GameScene 装图 -> 机位(夹具确认 / 自动候选)-> 树怪经
## net_world 的真实生成路径(entity_spawned -> MobView 工厂 -> 贴地)-> 记录 sample_height、
## 怪物世界位置、ground offset、材质、实际动作路径 -> 固定机位截图。
##
## 判定边界:
## - 平地(足迹内地形高差 <= FLAT_FOOTPRINT_M)上 wait 各采样帧都悬空/埋地超过 GROUND_GAP_M 才算失败;
## AABB 底部不是足部,坡地只记录数据交人工看脚/根部接触,不据此宣布足部 IK 完成。
## - 自动候选机位、未确认机位、缺失的传送点、headless(无 Metal 截图)一律 BLOCKED,不当 PASS。
## - 这里没有服务器刷新:选中/攻击/死亡/拾取的实网证据由 playable_live_test 单独记录。
##
## 必测用例由 required_case_ids(maps) 决定(script/forest_map_render_test.sh 在启动前写同一份列表),
## Metal 截图的人工签核是发布清单里单独的 manual 项,不由本进程判定。
##
## 退出码:单独运行 0 PASS / 1 FAIL / 2 BLOCKED;由 runner 启动(有 MT_PLAYABLE_RUN_ID)时写出报告即 0
## 判定交给 runner。客户端只写 client-report.json、events 与 forest-map-evidence.json。
const GameScene = preload("res://game_scene.gd")
const Fixtures = preload("res://gamescene_test.gd")
const MobView = preload("res://ui/mob_view.gd")
const Config = preload("res://testing/playable_config.gd")
const Viewpoints = preload("res://testing/forest_viewpoints.gd")
const PlayableReport = preload("res://testing/playable_report.gd")
var failures := 0
## root/npclist.txt 23012307 / 23112315;未由服务器确认前只是候选。
const CANDIDATE_RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]
const WAIT_SAMPLES := [0.0, 0.25, 0.5, 0.75]
const GRID_STEP_M := 4.0
const FOOTPRINT_HALF_M := 1.5
## 平地判定:怪物包围盒足迹内地形最高/最低差。超过即交人工接触检查,不做自动悬空判定。
const FLAT_FOOTPRINT_M := 0.10
## 平地 wait 姿态 AABB 底部与地形高度的允许差(米)。校准记录见 CALIBRATION。
const GROUND_GAP_M := 0.10
const CALIBRATION := "GROUND_GAP_M=0.10 m (2026-09-11, Metal, metin2_map_trent auto viewpoints): MobView grounds the bind-pose bottom via Metin2Model ground_offset; over 16 flat placements of the 12 candidate races the largest wait-loop AABB-bottom gap was 0.026 m (ent_trent, sinks slightly). 0.10 m keeps ~4x margin while still catching a missing/doubled ground offset (0.04-0.15 m per race). Changes need a recorded reason."
const ROOT_GAP_M := 0.001
const MAIN_VID := 1000
const MOB_VID_BASE := 60000
const CHAR_TYPE_MONSTER := 2
const CAMERA_PITCH_DEG := 28.0
const DEFAULT_TIMEOUT_SECONDS := 900
var _out := ""
var _report := PlayableReport.new()
var _evidence := {}
var _finished := false
func _ready() -> void:
call_deferred("run")
func _fail(message: String) -> void:
failures += 1
printerr("FOREST MAP FAIL: " + message)
func run() -> void:
var map_key := OS.get_environment("MT_FOREST_MAP")
_out = OS.get_environment("MT_TEST_OUTPUT")
if _out.is_empty():
_out = OS.get_environment("MT_RENDER_OUTPUT")
if _out.is_empty():
_out = ProjectSettings.globalize_path("user://forest-map-%d" % Time.get_unix_time_from_system())
DirAccess.make_dir_recursive_absolute(_out)
var timeout := int(OS.get_environment("MT_FOREST_TIMEOUT_SECONDS")) if OS.get_environment("MT_FOREST_TIMEOUT_SECONDS").is_valid_int() else DEFAULT_TIMEOUT_SECONDS
get_tree().create_timer(timeout).timeout.connect(_on_watchdog)
var headless := DisplayServer.get_name() == "headless"
_evidence = {"schema_version": 1, "scope": "offline production GameScene + MobView placement; no server spawn, no live target/attack/pickup evidence",
"headless": headless, "assets": AssetRoot.path(), "calibration": CALIBRATION,
"thresholds": {"flat_footprint_m": FLAT_FOOTPRINT_M, "ground_gap_m": GROUND_GAP_M, "root_gap_m": ROOT_GAP_M},
"maps": [], "manual_review": ["Metal screenshots: tree monster silhouettes, materials, foot/root contact on slopes"],
"live_evidence": "separate: playable_live_test per-map enter/select/attack/dead/pickup/leave"}
var config := {}
var config_path := OS.get_environment("MT_PLAYABLE_CONFIG")
var loaded_config := Config.load_file(config_path) if not config_path.is_empty() else {"ok": false, "config": {}}
if map_key.is_empty() and loaded_config.ok:
map_key = String(loaded_config.config.get("map_key", ""))
if map_key.is_empty():
map_key = "outdoortrent/metin2_map_trent"
var race := int(OS.get_environment("MT_FOREST_MOB_RACE")) if not OS.get_environment("MT_FOREST_MOB_RACE").is_empty() else 2301
var output := OS.get_environment("MT_TEST_OUTPUT")
if output.is_empty():
output = ProjectSettings.globalize_path("res://../build/rendering/forest-map-%d" % Time.get_unix_time_from_system())
DirAccess.make_dir_recursive_absolute(output)
var playable_report := PlayableReport.new()
playable_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded_config.config if loaded_config.ok else {})
get_tree().root.size = Vector2i(1280, 720)
print("FOREST MAP: loading %s" % map_key)
var client := Fixtures.FakeClient.new()
# Server-space values chosen inside the corresponding local map rectangle.
# GameScene/MapCoord performs the actual base-position conversion.
var base_x := 0.0 if map_key == "outdoortrent/metin2_map_trent" else 1049600.0
var server_x := base_x + 12800.0
var server_y := 12800.0
client.spawn(1000, "ForestRender", Vector3(server_x * 0.01, 0.0, -server_y * 0.01), true)
var scene := GameScene.new()
get_tree().root.add_child(scene)
await scene.setup(client, AssetRoot.path(), map_key)
print("FOREST MAP: GameScene setup complete")
if not scene._map_loaded():
_fail("map did not load: %s" % map_key)
if not scene._model_built or scene.player == null:
_fail("local player model is not ready")
var entities := scene.get_node_or_null("Entities")
if entities == null:
_fail("GameScene entity mount is missing")
var positions := [Vector3(12.0, 0.0, 18.0), Vector3(15.0, 0.0, 21.0)]
var monster := MobView.new()
monster.name = "ForestTreeMonster_%d" % race
if not monster.build(AssetRoot.path(), null, race):
_fail("tree monster build failed race=%d" % race)
if not config_path.is_empty() and FileAccess.file_exists(config_path):
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(config_path))
config = parsed if parsed is Dictionary else {}
_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "forest_render", config, OS.get_environment("MT_TEST_EVENTS"))
var maps: Array[String] = []
for key in OS.get_environment("MT_FOREST_MAPS").split(",", false):
if not key.strip_edges().is_empty():
maps.append(key.strip_edges())
if maps.is_empty() and not String(config.get("map_key", "")).strip_edges().is_empty():
maps.append(String(config.map_key).strip_edges())
_report.set_required_cases(required_case_ids(maps))
var vp_file := Viewpoints.load_file(OS.get_environment("MT_FOREST_VIEWPOINTS"))
var races := parse_races(OS.get_environment("MT_FOREST_RACES"))
var config_errors: Array[String] = []
if maps.is_empty():
config_errors.append("no map: set MT_FOREST_MAPS or config map_key from map-assets.json")
var ids := {}
for map_key in maps:
if not is_safe_map_key(map_key):
config_errors.append("map_key %s must be a relative asset path without '..'" % map_key)
elif ids.has(map_key.get_file()):
config_errors.append("map_key %s repeats map id %s" % [map_key, map_key.get_file()])
ids[map_key.get_file()] = true
if not vp_file.ok:
config_errors.append_array(vp_file.errors)
if not races.ok:
config_errors.append("MT_FOREST_RACES must be a comma list of positive mob vnums")
if not config_errors.is_empty():
_report.add_case("MAP-FOREST-CONFIG", "BLOCKED", "; ".join(config_errors))
else:
entities.add_child(monster)
monster.position = positions[0]
monster.set_anim_state("wait")
client.spawn(23001, "TreeMonster", Vector3(positions[0].x * 100.0, 0.0, -positions[0].z * 100.0))
_report.add_case("MAP-FOREST-CONFIG", "PASS", "maps=%s viewpoint_fixture=%s" % [",".join(maps),
"yes" if not OS.get_environment("MT_FOREST_VIEWPOINTS").is_empty() else "none"])
get_tree().root.size = Vector2i(1280, 720)
for map_key in maps:
await _run_map(map_key, vp_file.data, races.races, headless)
_finish()
static func required_case_ids(maps: Array) -> Array[String]:
var ids: Array[String] = ["MAP-FOREST-CONFIG"]
for map_key in maps:
var map_id := String(map_key).get_file()
ids.append("MAP-FOREST-LOAD-" + map_id)
for kind in Viewpoints.KINDS:
ids.append("MAP-FOREST-VP-%s-%s" % [map_id, kind])
ids.append("MAP-FOREST-MOTION-" + map_id)
return ids
static func is_safe_map_key(map_key: String) -> bool:
return not map_key.is_empty() and not map_key.begins_with("/") and not ("\\" in map_key) \
and not (".." in map_key.split("/"))
static func parse_races(text: String) -> Dictionary:
var out := {"ok": true, "races": []}
for part in text.split(",", false):
var value := part.strip_edges()
if not value.is_valid_int() or int(value) <= 0:
out.ok = false
continue
out.races.append(int(value))
return out
func _on_watchdog() -> void:
if _finished:
return
_report.add_failure("forest render watchdog: run did not finish (script error or hang)")
_finish()
func _finish() -> void:
if _finished:
return
_finished = true
var report_path := OS.get_environment("MT_TEST_REPORT")
if report_path.is_empty():
report_path = _out.path_join("client-report.json")
var final := _report.finish()
_evidence["status"] = final.get("status", "FAIL")
# 自动判定之外仍需人工看图;写进报告,避免把自动 PASS 读成视觉签核完成。
final["manual_signoff"] = {"required": true, "status": "PENDING", "evidence": "forest-*.png + forest-map-evidence.json",
"scope": "tree monster silhouettes/materials, slope foot-root contact, dense-forest occlusion, dead poses"}
_evidence["manual_signoff"] = final.manual_signoff
var f := FileAccess.open(_out.path_join("forest-map-evidence.json"), FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(_evidence, "\t"))
f.close()
var written := _report.write(report_path)
var status := String(final.get("status", "FAIL"))
print("FOREST MAP RENDER: status=%s failures=%d blocked=%d output=%s" % [status,
final.get("failures", []).size(), final.get("blocked", []).size(), _out])
for line in final.get("failures", []):
printerr("FOREST MAP FAIL: " + String(line))
var code := 0 if status == "PASS" else (1 if status == "FAIL" else 2)
if not written:
printerr("FOREST MAP RENDER: cannot write client report")
code = 1
elif not OS.get_environment("MT_PLAYABLE_RUN_ID").is_empty():
code = 0 # 由父 runner 在真实退出后判定;非零只表示客户端没能写出报告。
# 被释放的 AudioStreamPlayer3D 的 playback 由音频线程异步移除;立即 quit 会报 ObjectDB 泄漏。
get_tree().create_timer(0.3, true, false, true).timeout.connect(func() -> void: get_tree().quit(code))
func _run_map(map_key: String, vp_data: Dictionary, races_override: Array, headless: bool) -> void:
var map_id := map_key.get_file()
var map_ev := {"map_key": map_key, "viewpoints": []}
_evidence.maps.append(map_ev)
var bounds := {}
var probe := GameScene.new()
for name in ["setting.txt", "Setting.txt"]:
var p := AssetRoot.path().path_join(map_key).path_join(name)
if FileAccess.file_exists(p):
bounds = probe._map_bounds(p)
break
probe.free()
if bounds.is_empty():
_report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "Setting.txt with BasePosition/MapSize not found for %s" % map_key)
return
var plan := Viewpoints.plan(vp_data, map_key, bounds)
if not plan.errors.is_empty():
_report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "viewpoint fixture invalid: " + "; ".join(plan.errors))
return
var size_m := Vector2(bounds.size) * Viewpoints.MAP_CELL_CM * 0.01
var races: Array = plan.races.duplicate()
var race_source := "fixture"
if not races_override.is_empty():
races = races_override.duplicate()
race_source = "MT_FOREST_RACES"
if races.is_empty():
races = CANDIDATE_RACES.duplicate()
race_source = "candidate_only"
map_ev["bounds"] = {"base_cm": [bounds.base.x, bounds.base.y], "size_tiles": [bounds.size.x, bounds.size.y]}
map_ev["races"] = races
map_ev["race_source"] = race_source
var client := Fixtures.FakeClient.new()
client.name = "ForestFakeClient"
get_tree().root.add_child(client)
var centre_cm: Vector2 = bounds.base + size_m * 50.0
client.spawn(MAIN_VID, "ForestRender", _net_pos(centre_cm), true)
var scene := GameScene.new()
scene.name = "ForestGameScene"
get_tree().root.add_child(scene)
var started := Time.get_ticks_msec()
await scene.setup(client, AssetRoot.path(), map_key)
var world: Node = scene.world
while world and int(world.call("get_load_report").get("stream_queue", 0)) > 0:
await get_tree().process_frame
if monster.model == null or monster.model.call("get_visual_aabb").size.length() <= 0.0:
_fail("tree monster has no visible geometry")
if scene.cam and scene.cam.has_method("snap_to_target"):
scene.cam.snap_to_target()
var load_report: Dictionary = world.call("get_load_report") if world else {}
map_ev["load"] = {"ms": Time.get_ticks_msec() - started, "report": load_report, "scene_map_path": scene.map_path}
var load_errors: Array[String] = []
if not scene._map_loaded():
load_errors.append("map did not load")
if scene.map_path != map_key:
load_errors.append("GameScene resolved %s instead of %s" % [scene.map_path, map_key])
for key in ["chunks_failed", "objects_missing_model"]:
if int(load_report.get(key, 0)) > 0:
load_errors.append("%s=%d" % [key, int(load_report.get(key, 0))])
if scene.net_world == null or scene.proto == null:
load_errors.append("net_world/mob_proto not ready; monsters would fall back to capsules")
_report.record("map_loaded", "forest_render", "MAP-FOREST-LOAD-" + map_id, 0, MAIN_VID, 0,
{"map_key": map_key, "elapsed_ms": map_ev.load.ms, "count": int(load_report.get("chunks_built", 0)),
"reason_code": "ok" if load_errors.is_empty() else "load_failed"})
_report.add_case("MAP-FOREST-LOAD-" + map_id, "FAIL" if not load_errors.is_empty() else "PASS",
"; ".join(load_errors) if not load_errors.is_empty() else "chunks=%d objects=%d trees=%d" % [
int(load_report.get("chunks_built", 0)), int(load_report.get("objects_placed", 0)), int(load_report.get("trees_placed", 0))])
if not load_errors.is_empty():
await _teardown(scene, client)
return
var viewpoints: Array = plan.viewpoints.duplicate()
var auto_kinds: Array = plan.missing_kinds.filter(func(k: String) -> bool: return k in Viewpoints.AUTO_KINDS)
if not auto_kinds.is_empty():
var trees := _tree_positions(world)
map_ev["tree_trunks_found"] = trees.size()
if not trees.is_empty():
var r := Rect2(trees[0], Vector2.ZERO)
for t in trees:
r = r.expand(t)
map_ev["tree_trunk_bounds_m"] = [r.position.x, r.position.y, r.end.x, r.end.y]
# headless 的 RenderingServerDummy 不保存 MultiMesh 实例数据,读回全是单位变换。
if trees.size() > 1 and r.size.is_zero_approx():
map_ev["tree_positions"] = "unavailable: MultiMesh instance transforms are not readable from the headless rendering server"
trees = PackedVector2Array()
var candidates := Viewpoints.pick_candidates(_terrain_samples(world, size_m), trees, size_m, auto_kinds)
for c: Dictionary in candidates:
viewpoints.append({"id": "auto-" + String(c.kind), "kind": c.kind, "status": "auto_candidate", "source": "auto",
"confirmed_by": "", "camera_yaw_deg": Viewpoints.DEFAULT_YAW_DEG, "local_m": c.p,
"server_cm": Viewpoints.to_server_cm(bounds, c.p),
"selection": {"range_m": c.range_m, "slope_ratio": c.slope_ratio, "tree_density": c.density}})
var motion_errors: Array[String] = []
var fallbacks: Array = []
var by_kind := {}
for vp: Dictionary in viewpoints:
var vp_ev := await _run_viewpoint(scene, client, world, vp, races, map_id, headless)
map_ev.viewpoints.append(vp_ev)
for line: String in vp_ev.motion_errors:
if not (line in motion_errors):
motion_errors.append(line)
fallbacks.append_array(vp_ev.fallbacks)
if not by_kind.has(vp.kind):
by_kind[vp.kind] = []
by_kind[vp.kind].append(vp_ev)
_report.record("viewpoint", "forest_render", "MAP-FOREST-VP-%s-%s" % [map_id, vp.kind], 0, 0, 0,
{"map_key": map_key, "state": vp.kind, "reason_code": vp.status, "count": vp_ev.mobs.size(),
"evidence": vp_ev.screenshots, "selected": vp.id})
for kind in Viewpoints.KINDS:
_add_kind_case(map_id, kind, by_kind.get(kind, []), map_ev, headless)
map_ev["reference_fallbacks"] = fallbacks
if viewpoints.is_empty():
_report.add_case("MAP-FOREST-MOTION-" + map_id, "BLOCKED", "no viewpoint to place tree monsters")
else:
_report.add_case("MAP-FOREST-MOTION-" + map_id, "FAIL" if not motion_errors.is_empty() else "PASS",
"; ".join(motion_errors) if not motion_errors.is_empty() else "wait/run/dead via net_world; %d reference keep-current-motion fallbacks need live confirmation" % fallbacks.size())
await _teardown(scene, client)
## 每种机位一个必测用例:任一机位断言失败即 FAIL;只有“经确认 + Metal 截图 + 全部断言通过”才 PASS。
func _add_kind_case(map_id: String, kind: String, results: Array, map_ev: Dictionary, headless: bool) -> void:
var case_id := "MAP-FOREST-VP-%s-%s" % [map_id, kind]
var errors: Array[String] = []
var screenshots: Array = []
var confirmed: Array[String] = []
var unconfirmed: Array[String] = []
for r: Dictionary in results:
screenshots.append_array(r.screenshots)
for e: String in r.errors:
errors.append("%s: %s" % [r.id, e])
if r.status == "confirmed":
confirmed.append(String(r.id))
else:
unconfirmed.append("%s(%s @ server_cm %s)" % [r.id, r.status, r.server_cm])
if not errors.is_empty():
_report.add_case(case_id, "FAIL", "; ".join(errors), screenshots)
elif results.is_empty():
var why := "no confirmed viewpoint and no usable terrain candidate"
if kind == "warp":
why = "warp points cannot be derived offline; add a confirmed viewpoint"
elif kind == "dense" and map_ev.has("tree_positions"):
why = "no confirmed viewpoint; tree density needs a Metal run (%s)" % map_ev.tree_positions
_report.add_case(case_id, "BLOCKED", why)
elif headless:
_report.add_case(case_id, "BLOCKED", "headless run has no Metal screenshot", screenshots)
elif confirmed.is_empty() or not unconfirmed.is_empty():
_report.add_case(case_id, "BLOCKED", "viewpoints not confirmed by the test environment owner: %s" % ", ".join(unconfirmed), screenshots)
else:
_report.add_case(case_id, "PASS", "confirmed %s: %s" % [kind, ", ".join(confirmed)], screenshots)
func _run_viewpoint(scene: Node, client: Node, world: Node, vp: Dictionary, races: Array, map_id: String, headless: bool) -> Dictionary:
var ev := {"id": vp.id, "kind": vp.kind, "status": vp.status, "source": vp.source,
"server_cm": [vp.server_cm.x, vp.server_cm.y], "local_m": [vp.local_m.x, vp.local_m.y],
"camera_yaw_deg": vp.camera_yaw_deg, "selection": vp.get("selection", {}),
"mobs": [], "screenshots": [], "errors": [], "motion_errors": [], "fallbacks": []}
var centre: Vector2 = vp.local_m
var yaw := deg_to_rad(float(vp.camera_yaw_deg))
var to_camera := Vector3(sin(yaw), 0.0, cos(yaw))
var right := Vector3.UP.cross(to_camera).normalized()
var base: Vector2 = vp.server_cm - vp.local_m * 100.0
# 主角放到怪群侧面,避免挡住机位。
var player_local := centre + Vector2(right.x, right.z) * 12.0
client.ents[MAIN_VID].pos = _net_pos(base + player_local * 100.0)
scene._place_player_at_net_pos(client.ents[MAIN_VID].pos)
var vids: Array[int] = []
for i in races.size():
var vid := MOB_VID_BASE + i
vids.append(vid)
client.ents[vid] = {"vid": vid, "name": "Race%d" % races[i], "race": int(races[i]), "ch_type": CHAR_TYPE_MONSTER,
"pos": _net_pos(vp.server_cm), "is_main": false, "func": 0, "moving": false, "angle_deg": 0.0,
"hp": 100, "max_hp": 100, "dead": false}
client.entity_spawned.emit(client.ents[vid])
await get_tree().process_frame
await get_tree().process_frame
var image_name := "forest-map-%s.png" % map_key.get_file()
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
if get_tree().root.get_texture().get_image().save_png(output.path_join(image_name)) != OK:
_fail("map screenshot could not be saved")
var report := {"passed": failures == 0, "failures": failures, "map": map_key,
"tree_monster_race": race, "tree_monster_model": monster.model != null,
"map_loaded": scene._map_loaded(), "screenshot": image_name if DisplayServer.get_name() != "headless" else "",
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
"scope": "production GameScene map load plus offline tree-monster placement; no server spawn"}
var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(report, "\t"))
file.close()
var client_report_path := OS.get_environment("MT_TEST_REPORT")
if not client_report_path.is_empty():
playable_report.add_case("MAP-FOREST-01", "PASS" if failures == 0 else "FAIL",
"map=%s tree_monster_race=%d" % [map_key, race])
playable_report.write(client_report_path)
print("FOREST MAP RENDER: ", JSON.stringify(report))
# 按最大足迹排成面向相机的网格,再让 net_world 按新 pos 贴地。
var spacing := 3.5
for vid in vids:
var node: Node3D = scene.net_world.node_for(vid)
if node != null and node.has_method("resolve_motion") and node.model:
# get_visual_aabb 是模型本地单位(未乘 unit_scale),先变到世界米。
var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
spacing = maxf(spacing, maxf(box.size.x, box.size.z) + 1.0)
var cols := int(ceil(sqrt(float(races.size()))))
var rows := int(ceil(float(races.size()) / float(cols)))
for i in vids.size():
var offset := right * (float(i % cols) - (cols - 1) * 0.5) * spacing \
+ to_camera * (float(i / cols) - (rows - 1) * 0.5) * spacing
client.ents[vids[i]].pos = _net_pos(base + (centre + Vector2(offset.x, offset.z)) * 100.0)
for _i in 3:
await get_tree().process_frame
var cam := Camera3D.new()
cam.name = "ForestFixedCamera"
cam.fov = 50.0
scene.add_child(cam)
cam.current = true
var top := 0.0
for i in vids.size():
var node: Node3D = scene.net_world.node_for(vids[i])
var mob := await _measure_mob(node, int(races[i]), vids[i], world, ev)
ev.mobs.append(mob)
top = maxf(top, float(mob.get("height_m", 0.0)))
var ground := float(world.call("sample_height", centre.x, centre.y))
var look := Vector3(centre.x, ground + maxf(1.0, top * 0.5), centre.y)
var distance := maxf(12.0, spacing * float(maxi(cols, rows)) * 1.15)
var pitch := deg_to_rad(CAMERA_PITCH_DEG)
cam.position = look + (to_camera * cos(pitch) + Vector3.UP * sin(pitch)) * distance
cam.position.y = maxf(cam.position.y, float(world.call("sample_height", cam.position.x, cam.position.z)) + 1.5)
cam.look_at(look, Vector3.UP)
ev["camera"] = {"position": [cam.position.x, cam.position.y, cam.position.z], "look_at": [look.x, look.y, look.z], "fov": cam.fov,
"distance_m": distance, "grid_spacing_m": spacing}
for i in vids.size():
var node: Node3D = scene.net_world.node_for(vids[i])
if node == null:
continue
var anchor := node.global_position + Vector3.UP * 0.5
var screen := get_viewport().get_visible_rect().grow(-8.0)
if cam.is_position_behind(anchor) or not screen.has_point(cam.unproject_position(anchor)):
ev.errors.append("race %d is outside the fixed camera frame" % races[i])
if not headless:
await _screenshot(cam, "forest-%s-%s-wait.png" % [map_id, vp.id], ev)
# 生产路径动作:移动(资源无 RUN/WALK 时按旧客户端保留当前动作)与死亡。
for i in vids.size():
var node: Node3D = scene.net_world.node_for(vids[i])
if node != null and node.has_method("resolve_motion"):
node.anim.set_process(true)
client.ents[vids[i]].moving = true
for _i in 2:
await get_tree().process_frame
for i in vids.size():
_check_motion(scene.net_world.node_for(vids[i]), int(races[i]), "run", ev)
client.ents[vids[i]].moving = false
client.ents[vids[i]].dead = true
client.entity_dead.emit(vids[i])
await get_tree().create_timer(0.4).timeout
for i in vids.size():
var node: Node3D = scene.net_world.node_for(vids[i])
_check_motion(node, int(races[i]), "dead", ev)
if node != null and node.has_method("resolve_motion") and String(node.last_motion_resolution().path) != "":
if absf(node.rotation.x) > 0.01 or absf(node.rotation.z) > 0.01:
ev.motion_errors.append("race %d dead motion is tilted by the scene (rotation.x=%.2f); reference Die() only plays DEAD" % [races[i], node.rotation.x])
node.anim.set_process(false)
node.anim.call("set_time", maxf(0.0, float(node.anim.call("get_duration")) - 1.0 / 60.0))
var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
var h := float(world.call("sample_height", node.global_position.x, node.global_position.z))
for mob: Dictionary in ev.mobs:
if int(mob.vid) == vids[i]:
mob["dead_end_bottom_gap_m"] = box.position.y - h
if not headless:
await _screenshot(cam, "forest-%s-%s-dead.png" % [map_id, vp.id], ev)
for vid in vids:
client.ents.erase(vid)
client.entity_despawned.emit(vid)
cam.queue_free()
for _i in 2:
await get_tree().process_frame
return ev
func _measure_mob(node: Node3D, race: int, vid: int, world: Node, ev: Dictionary) -> Dictionary:
var out := {"race": race, "vid": vid}
if node == null or not node.has_method("resolve_motion") or node.model == null or node.anim == null:
ev.errors.append("race %d spawned without a MobView model (placeholder capsule)" % race)
out["model"] = false
return out
out["model"] = true
var anim: Node = node.anim
anim.set("blend_time", 0.0)
anim.set_process(false)
var root := node.global_position
var h_root := float(world.call("sample_height", root.x, root.z))
out["world_position"] = [root.x, root.y, root.z]
out["sample_height_m"] = h_root
out["root_gap_m"] = root.y - h_root
out["ground_offset_m"] = float(node.model.call("get_ground_offset"))
out["model_local_y_m"] = node.model.position.y
out["wait_resolution"] = node.last_motion_resolution()
out["wait_motion_path"] = String(anim.get("anim_path"))
out["materials"] = _material_report(node.model)
if absf(root.y - h_root) > ROOT_GAP_M:
ev.errors.append("race %d root is %.3fm off terrain; net_world grounding broken" % [race, root.y - h_root])
if String(out.wait_motion_path).is_empty():
ev.motion_errors.append("race %d has no wait motion after spawn" % race)
if int(out.materials.surfaces) == 0 or int(out.materials.textured) < int(out.materials.surfaces) or int(out.materials.invalid) > 0:
ev.errors.append("race %d materials: %s" % [race, out.materials])
var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
var half := Vector2(maxf(0.3, box.size.x * 0.5), maxf(0.3, box.size.z * 0.5))
var c := Vector2(box.get_center().x, box.get_center().z)
var heights: Array[float] = []
for d in [Vector2.ZERO, Vector2(half.x, half.y), Vector2(-half.x, half.y), Vector2(half.x, -half.y), Vector2(-half.x, -half.y)]:
heights.append(float(world.call("sample_height", c.x + d.x, c.y + d.y)))
var footprint_range: float = heights.max() - heights.min()
out["footprint_range_m"] = footprint_range
out["height_m"] = box.size.y
var samples: Array = []
var off := 0
var duration := float(anim.call("get_duration"))
for f in WAIT_SAMPLES:
anim.call("set_time", clampf(duration * f, 0.0, maxf(0.0, duration - 1.0 / 60.0)))
var b: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
var gap := b.position.y - h_root
samples.append({"fraction": f, "aabb_bottom_gap_m": gap})
if not b.position.is_finite() or not b.size.is_finite():
ev.errors.append("race %d wait sample %.2f has non-finite bounds" % [race, f])
if absf(gap) > GROUND_GAP_M:
off += 1
out["wait_samples"] = samples
anim.call("set_time", clampf(duration * 0.5, 0.0, maxf(0.0, duration - 1.0 / 60.0)))
if footprint_range <= FLAT_FOOTPRINT_M:
out["contact_check"] = "flat_auto"
if off == WAIT_SAMPLES.size():
ev.errors.append("race %d floats/buries on flat ground: every wait sample exceeds %.2fm (%s)" % [race, GROUND_GAP_M, samples])
else:
out["contact_check"] = "slope_manual"
return out
func _check_motion(node: Node3D, race: int, state: String, ev: Dictionary) -> void:
if node == null or not node.has_method("resolve_motion"):
return
var res: Dictionary = node.last_motion_resolution()
var path := String(node.anim.get("anim_path"))
if String(res.get("requested_state", "")) != state:
ev.motion_errors.append("race %d: net_world did not request %s (last=%s)" % [race, state, res.get("requested_state", "")])
return
if String(res.path).is_empty():
if state == "run" and String(res.fallback_reason) == "reference_keep_current_motion" and not path.is_empty():
ev.fallbacks.append({"race": race, "state": state, "kept_motion_path": path, "needs_live_confirmation": true})
else:
ev.motion_errors.append("race %d %s has no motion and is not a reference keep-current fallback" % [race, state])
return
if path != String(res.path):
ev.motion_errors.append("race %d %s resolved %s but plays %s" % [race, state, res.path, path])
if state == "dead" and (bool(node.anim.get("loop")) or not String(res.motion).contains("DEAD")):
ev.motion_errors.append("race %d dead plays %s loop=%s" % [race, res.motion, node.anim.get("loop")])
func _screenshot(cam: Camera3D, file_name: String, ev: Dictionary) -> void:
cam.current = true
# 证据图只拍 3D 场景:HUD 与血条层会让“非均匀”检查在场景全坏时仍然通过。
var hidden: Array[CanvasLayer] = []
for layer in get_tree().root.find_children("*", "CanvasLayer", true, false):
if (layer as CanvasLayer).visible:
(layer as CanvasLayer).visible = false
hidden.append(layer)
for _i in 3:
await get_tree().process_frame
await RenderingServer.frame_post_draw
var image := get_tree().root.get_texture().get_image()
for layer in hidden:
if is_instance_valid(layer):
layer.visible = true
if image == null or image.is_empty() or not _has_detail(image):
ev.errors.append("screenshot %s is empty or uniform" % file_name)
return
if image.save_png(_out.path_join(file_name)) != OK:
ev.errors.append("screenshot %s could not be saved" % file_name)
return
ev.screenshots.append(file_name)
func _has_detail(image: Image) -> bool:
var first := image.get_pixel(0, 0)
var differing := 0
var total := 0
for y in range(0, image.get_height(), 16):
for x in range(0, image.get_width(), 16):
total += 1
var p := image.get_pixel(x, y)
if absf(p.r - first.r) + absf(p.g - first.g) + absf(p.b - first.b) > 0.05:
differing += 1
return total > 0 and float(differing) / float(total) > 0.1
func _material_report(model: Node) -> Dictionary:
var out := {"surfaces": 0, "textured": 0, "two_sided": 0, "invalid": 0}
for node in model.find_children("*", "MeshInstance3D", true, false):
var instance := node as MeshInstance3D
if instance.mesh == null:
continue
for surface in instance.mesh.get_surface_count():
out.surfaces += 1
var material := instance.get_active_material(surface)
if material is ShaderMaterial and (material as ShaderMaterial).shader != null:
var sm := material as ShaderMaterial
if bool(sm.get_shader_parameter("use_texture")) and sm.get_shader_parameter("albedo_tex") != null:
out.textured += 1
if sm.shader.code.contains("cull_disabled"):
out.two_sided += 1
elif material is BaseMaterial3D:
if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null:
out.textured += 1
if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED:
out.two_sided += 1
else:
out.invalid += 1
return out
func _terrain_samples(world: Node, size_m: Vector2) -> Array:
var out: Array = []
var x := GRID_STEP_M * 0.5
while x < size_m.x:
var z := GRID_STEP_M * 0.5
while z < size_m.y:
var lo := INF
var hi := -INF
for d in [Vector2.ZERO, Vector2(FOOTPRINT_HALF_M, 0), Vector2(-FOOTPRINT_HALF_M, 0), Vector2(0, FOOTPRINT_HALF_M), Vector2(0, -FOOTPRINT_HALF_M)]:
var h := float(world.call("sample_height", x + d.x, z + d.y))
lo = minf(lo, h)
hi = maxf(hi, h)
out.append({"p": Vector2(x, z), "range_m": hi - lo, "span_m": FOOTPRINT_HALF_M * 2.0,
"blocked": bool(world.call("is_blocked", x, z))})
z += GRID_STEP_M
x += GRID_STEP_M
return out
func _tree_positions(world: Node) -> PackedVector2Array:
var out := PackedVector2Array()
for node in world.find_children("Trees_*", "MultiMeshInstance3D", true, false):
var mm := (node as MultiMeshInstance3D).multimesh
if mm == null:
continue
for i in mm.instance_count:
var origin := ((node as MultiMeshInstance3D).global_transform * mm.get_instance_transform(i)).origin
out.append(Vector2(origin.x, origin.z))
return out
func _net_pos(server_cm: Vector2) -> Vector3:
return Vector3(server_cm.x * 0.01, 0.0, -server_cm.y * 0.01)
func _teardown(scene: Node, client: Node) -> void:
scene.queue_free()
client.queue_free()
await get_tree().process_frame
get_tree().quit(1 if failures else 0)
for _i in 3:
await get_tree().process_frame
+220 -50
View File
@@ -1,17 +1,36 @@
extends SceneTree
## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收。
## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收FIRST-MAC-PLAYABLE §8.2
## 这不是服务器刷新证明;真实地图中的选中、战斗和掉落由 playable_live_test 承担。
##
## 每个 race × CPU/GPU × wait/run/attack/damage/dead,在动作起点/25%/50%/75%/末段取样:
## - 报告 requested_state / resolved_motion / fallback_reason,实际播放路径必须等于解析结果;
## - 资源确无动作时按旧客户端规则(GetMotionKey 失败 -> 保留当前动作)单独判定,
## 只允许移动循环动作走这条回退,并写进 report.fallbacks
## - 贴图实际加载、材质有 shader、包围盒有限;
## - 同一姿态 CPU/GPU 包围盒差 <= max(1cm, 高度0.5%)
## - 非 headless 时输出正面、侧面和每 race 动作 contact sheet,截图不能是纯背景。
## env: MT_RENDER_OUTPUT 输出目录;MT_FOREST_RACES 逗号分隔的 race 子集(调试用)。
const MobView = preload("res://ui/mob_view.gd")
const RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]
const STATES := ["wait", "run", "attack", "damage", "dead"]
## 旧客户端 CActorInstance::Move -> SetLoopMotion(RUN/WALK):缺动作时直接返回保留当前循环。
const MOVE_STATES := ["run"]
const SAMPLES := [0.0, 0.25, 0.5, 0.75, 1.0]
const SIZE := 640
const THUMB := 128
const BACKGROUND := Color(0.08, 0.1, 0.14)
const TOLERANCE_FLOOR_M := 0.01
const TOLERANCE_HEIGHT_RATIO := 0.005
const MIN_FOREGROUND_RATIO := 0.002
var _output := ""
var _failures: Array[String] = []
var _fallbacks: Array[Dictionary] = []
var _pose_bounds := {}
var _results: Array[Dictionary] = []
var _races: Array = RACES
func _initialize() -> void:
call_deferred("run")
@@ -25,6 +44,11 @@ func run() -> void:
if _output.is_empty():
_output = ProjectSettings.globalize_path("res://../build/rendering/forest-mobs-%d" % Time.get_unix_time_from_system())
DirAccess.make_dir_recursive_absolute(_output)
var subset := OS.get_environment("MT_FOREST_RACES").strip_edges()
if not subset.is_empty():
_races = []
for part in subset.split(",", false):
_races.append(int(part))
root.size = Vector2i(SIZE, SIZE)
_build_lighting()
var cam := Camera3D.new()
@@ -34,80 +58,226 @@ func run() -> void:
cam.make_current()
for gpu in ["0", "1"]:
OS.set_environment("MTGODOT_GPUSKIN", gpu)
for race in RACES:
await _check_race(race, gpu, cam)
for race in _races:
await _check_race(int(race), gpu, cam)
OS.set_environment("MTGODOT_GPUSKIN", "0")
var report := {"passed": _failures.is_empty(), "failures": _failures, "races": RACES,
"states": STATES, "gpu_modes": ["0", "1"], "results": _results,
"assets": AssetRoot.path(), "viewport": str(root.size),
"headless": DisplayServer.get_name() == "headless",
var headless := DisplayServer.get_name() == "headless"
var report := {"passed": _failures.is_empty(), "failures": _failures, "races": _races,
"states": STATES, "gpu_modes": ["0", "1"], "sample_fractions": SAMPLES,
"tolerance_rule": "max(%.2fm, %.1f%% of posed height)" % [TOLERANCE_FLOOR_M, TOLERANCE_HEIGHT_RATIO * 100.0],
"fallback_rule": "reference CRaceData::GetMotionKey miss -> SetLoopMotion returns and the current loop keeps playing",
"fallbacks": _fallbacks, "results": _results,
"assets": AssetRoot.path(), "viewport": str(root.size), "headless": headless,
"screenshots": not headless,
"manual_review": "contact sheets and front/side views need one human sign-off; AABB bottom is not foot contact",
"scope": "offline tree-monster assembly; no server spawn or 40250 pixel parity"}
var file := FileAccess.open(_output.path_join("report.json"), FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(), "results": _results.size(), "output": _output}))
else:
_fail("cannot write report.json")
print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(),
"fallbacks": _fallbacks.size(), "results": _results.size(), "output": _output}))
# Batch marker (rendering_batch_test.sh); the JSON line above keeps the details.
print("PASS: forest_mob_render_test (manual sign-off pending)" if _failures.is_empty() else "FAIL: forest_mob_render_test")
quit(0 if _failures.is_empty() else 1)
func _check_race(race: int, gpu: String, cam: Camera3D) -> void:
var tag := "race=%d GPU=%s" % [race, gpu]
var view := MobView.new()
view.name = "ForestMob_%d_%s" % [race, gpu]
root.add_child(view)
if not view.build(AssetRoot.path(), null, race):
_fail("race=%d GPU=%s build returned false" % [race, gpu])
_fail("%s build returned false" % tag)
view.queue_free()
await process_frame
return
var row := {"race": race, "gpu_skin": gpu == "1", "states": {}}
for state in STATES:
var requested := view.get_motion_path(state)
if requested.is_empty() or not FileAccess.file_exists(requested):
_fail("race=%d GPU=%s state=%s has no resolved .msa" % [race, gpu, state])
continue
# 交叉淡入只在 _process 里推进;取样要看动作本身的姿态,不看上一动作的残留混合。
if view.anim:
view.anim.set("blend_time", 0.0)
var row := {"race": race, "gpu_skin": gpu == "1", "directory": view._dir,
"materials": _material_report(view.model, tag), "states": {}, "views": {}, "contact_sheet": ""}
var shoot := DisplayServer.get_name() != "headless"
var sheet: Image = null
if shoot:
sheet = Image.create(THUMB * SAMPLES.size(), THUMB * STATES.size(), false, Image.FORMAT_RGBA8)
sheet.fill(BACKGROUND)
var frame := {}
for state_index in STATES.size():
var state: String = STATES[state_index]
var resolution: Dictionary = view.resolve_motion(state)
var before := String(view.anim.get("anim_path")) if view.anim else ""
view.set_anim_state(state)
if view.anim == null:
_fail("race=%d GPU=%s state=%s has no animation player" % [race, gpu, state])
_fail("%s state=%s has no animation player" % [tag, state])
continue
var entry := {"requested_state": state, "resolved_motion": String(resolution.motion),
"motion_path": String(resolution.path), "fallback_reason": String(resolution.fallback_reason),
"status": "PASS", "samples": []}
row.states[state] = entry
var actual := String(view.anim.get("anim_path"))
if String(resolution.path).is_empty():
if state in MOVE_STATES and resolution.fallback_reason == "reference_keep_current_motion" and actual == before and not before.is_empty():
entry.status = "REFERENCE_FALLBACK"
entry["kept_motion_path"] = actual
_fallbacks.append({"race": race, "gpu_skin": gpu == "1", "requested_state": state,
"kept_motion_path": actual, "fallback_reason": resolution.fallback_reason,
"needs_live_confirmation": "server movement of this race shows the kept loop while its position changes"})
else:
entry.status = "FAIL"
_fail("%s state=%s has no motion (%s; still playing %s)" % [tag, state, resolution.fallback_reason, actual])
continue
if not FileAccess.file_exists(String(resolution.path)):
entry.status = "FAIL"
_fail("%s state=%s resolved a missing file %s" % [tag, state, resolution.path])
continue
if actual != String(resolution.path):
entry.status = "FAIL"
_fail("%s state=%s resolved %s but played %s" % [tag, state, resolution.path, actual])
continue
if state == "dead" and bool(view.anim.get("loop")):
_fail("%s dead motion is looping" % tag)
view.anim.set_process(true)
await process_frame
var actual := String(view.anim.get("anim_path"))
if actual != requested:
_fail("race=%d GPU=%s state=%s resolved %s but played %s" % [race, gpu, state, requested, actual])
var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 1.0
var fraction := 0.5
if state == "dead":
fraction = 0.95
view.anim.call("set_time", maxf(0.0, duration * fraction))
view.anim.set_process(false)
await process_frame
var bounds: AABB = view.model.call("get_visual_aabb")
if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0:
_fail("race=%d GPU=%s state=%s has invalid visual AABB" % [race, gpu, state])
_pose_bounds["%d/%s" % [race, state]] = bounds if gpu == "0" else _pose_bounds.get("%d/%s" % [race, state], AABB())
if gpu == "1":
var cpu_bounds: AABB = _pose_bounds.get("%d/%s" % [race, state], AABB())
var tolerance := maxf(0.01, maxf(cpu_bounds.size.length(), bounds.size.length()) * 0.005)
if cpu_bounds.size.length() <= 0.0 or cpu_bounds.position.distance_to(bounds.position) > tolerance or cpu_bounds.size.distance_to(bounds.size) > tolerance:
_fail("race=%d state=%s CPU/GPU AABB differs beyond %.4fm" % [race, state, tolerance])
var image_name := ""
if DisplayServer.get_name() != "headless":
var centre: Vector3 = view.model.global_transform * bounds.get_center()
var visual_size: float = bounds.size.length() * view.model.scale.length() / sqrt(3.0)
var distance: float = maxf(3.0, visual_size * 1.8)
cam.position = centre + Vector3(distance * 0.35, distance * 0.22, distance)
cam.look_at(centre, Vector3.UP)
await RenderingServer.frame_post_draw
image_name = "mob-%d-%s.png" % [race, state] if gpu == "1" else "mob-%d-%s-cpu.png" % [race, state]
if root.get_texture().get_image().save_png(_output.path_join(image_name)) != OK:
_fail("race=%d state=%s screenshot save failed" % [race, state])
row.states[state] = {"requested_motion": requested, "resolved_motion": actual,
"duration": duration, "sample_fraction": fraction,
"aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z],
"aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z], "screenshot": image_name}
var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 0.0
entry["duration"] = duration
if duration <= 0.0:
entry.status = "FAIL"
_fail("%s state=%s has zero duration" % [tag, state])
continue
for sample_index in SAMPLES.size():
var fraction: float = SAMPLES[sample_index]
# 末段取最后一帧之前,避免非循环动作 set_time(duration) 被播放器夹回/结束。
var at := clampf(duration * fraction, 0.0, maxf(0.0, duration - 1.0 / 60.0))
view.anim.call("set_time", at)
await process_frame
var sample := _sample_pose(view, race, gpu, state, fraction, tag)
sample["time"] = at
entry.samples.append(sample)
if not bool(sample.valid):
entry.status = "FAIL"
if shoot:
if frame.is_empty():
frame = _frame_for(view)
var image := await _capture(cam, frame, Vector3(0.35, 0.22, 1.0))
if not _has_foreground(image):
entry.status = "FAIL"
_fail("%s state=%s sample=%.2f screenshot is empty" % [tag, state, fraction])
image.convert(Image.FORMAT_RGBA8)
image.resize(THUMB, THUMB, Image.INTERPOLATE_BILINEAR)
sheet.blit_rect(image, Rect2i(0, 0, THUMB, THUMB), Vector2i(sample_index * THUMB, state_index * THUMB))
if state == "wait" and is_equal_approx(fraction, 0.5):
for view_name in ["front", "side"]:
var direction := Vector3(0.0, 0.18, 1.0) if view_name == "front" else Vector3(1.0, 0.18, 0.0)
var shot := await _capture(cam, frame, direction)
var file_name := "mob-%d-%s-%s.png" % [race, view_name, "gpu" if gpu == "1" else "cpu"]
if not _has_foreground(shot) or shot.save_png(_output.path_join(file_name)) != OK:
_fail("%s %s view screenshot failed" % [tag, view_name])
row.views[view_name] = file_name
if shoot:
var sheet_name := "mob-%d-sheet-%s.png" % [race, "gpu" if gpu == "1" else "cpu"]
if not _has_foreground(sheet) or sheet.save_png(_output.path_join(sheet_name)) != OK:
_fail("%s contact sheet save failed" % tag)
row.contact_sheet = sheet_name
_results.append(row)
view.queue_free()
await process_frame
## 同一 race/state/fractionCPU 先记录,GPU 比较。容差 max(1cm, 高度0.5%),米制世界空间。
func _sample_pose(view: Node3D, race: int, gpu: String, state: String, fraction: float, tag: String) -> Dictionary:
var local: AABB = view.model.call("get_visual_aabb")
var bounds: AABB = view.model.global_transform * local
var out := {"fraction": fraction, "valid": true,
"aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z],
"aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z]}
if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0:
out.valid = false
_fail("%s state=%s sample=%.2f has invalid visual AABB" % [tag, state, fraction])
return out
var key := "%d/%s/%.2f" % [race, state, fraction]
if gpu == "0":
_pose_bounds[key] = bounds
return out
if not _pose_bounds.has(key):
out.valid = false
_fail("%s state=%s sample=%.2f has no CPU pose to compare" % [tag, state, fraction])
return out
var cpu: AABB = _pose_bounds[key]
var tolerance := maxf(TOLERANCE_FLOOR_M, maxf(cpu.size.y, bounds.size.y) * TOLERANCE_HEIGHT_RATIO)
var delta := maxf(cpu.position.distance_to(bounds.position), cpu.end.distance_to(bounds.end))
out["cpu_gpu_delta_m"] = delta
out["tolerance_m"] = tolerance
if delta > tolerance:
out.valid = false
_fail("%s state=%s sample=%.2f CPU/GPU AABB differs %.4fm > %.4fm" % [tag, state, fraction, delta, tolerance])
return out
## 贴图实际绑定到材质、材质有 shader。双面/混合按资源记录,不做统一强制。
func _material_report(model: Node, tag: String) -> Dictionary:
var surfaces := 0
var textured := 0
var two_sided := 0
var invalid := 0
for node in model.find_children("*", "MeshInstance3D", true, false):
var instance := node as MeshInstance3D
if instance.mesh == null:
continue
for surface in instance.mesh.get_surface_count():
surfaces += 1
var material := instance.get_active_material(surface)
var shader_material := material as ShaderMaterial
if shader_material != null:
if shader_material.shader == null:
invalid += 1
continue
if bool(shader_material.get_shader_parameter("use_texture")) and shader_material.get_shader_parameter("albedo_tex") != null:
textured += 1
if shader_material.shader.code.contains("cull_disabled"):
two_sided += 1
elif material is BaseMaterial3D:
if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null:
textured += 1
if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED:
two_sided += 1
else:
invalid += 1
if surfaces == 0:
_fail("%s model has no mesh surfaces" % tag)
if invalid > 0:
_fail("%s has %d surfaces without a valid material" % [tag, invalid])
if textured < surfaces:
_fail("%s only %d/%d surfaces have a loaded texture" % [tag, textured, surfaces])
return {"surfaces": surfaces, "textured": textured, "two_sided": two_sided, "invalid": invalid}
## 按首个采样姿态固定机位,同一 race 的所有截图可直接对比。
func _frame_for(view: Node3D) -> Dictionary:
var bounds: AABB = view.model.global_transform * (view.model.call("get_visual_aabb") as AABB)
var radius := maxf(0.5, bounds.size.length() * 0.5)
return {"centre": bounds.get_center(), "distance": maxf(3.0, radius * 3.6)}
func _capture(cam: Camera3D, frame: Dictionary, direction: Vector3) -> Image:
var centre: Vector3 = frame.centre
cam.position = centre + direction.normalized() * float(frame.distance)
cam.look_at(centre, Vector3.UP)
await RenderingServer.frame_post_draw
return root.get_texture().get_image()
func _has_foreground(image: Image) -> bool:
if image == null or image.is_empty():
return false
var hits := 0
var step := 4
var total := 0
for y in range(0, image.get_height(), step):
for x in range(0, image.get_width(), step):
total += 1
var c := image.get_pixel(x, y)
if absf(c.r - BACKGROUND.r) + absf(c.g - BACKGROUND.g) + absf(c.b - BACKGROUND.b) > 0.06:
hits += 1
return total > 0 and float(hits) / float(total) >= MIN_FOREGROUND_RATIO
func _build_lighting() -> void:
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-42, 155, 0)
@@ -116,7 +286,7 @@ func _build_lighting() -> void:
var env := WorldEnvironment.new()
var settings := Environment.new()
settings.background_mode = Environment.BG_COLOR
settings.background_color = Color(0.08, 0.1, 0.14)
settings.background_color = BACKGROUND
settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
settings.ambient_light_color = Color(0.55, 0.58, 0.65)
settings.ambient_light_energy = 0.8
+92
View File
@@ -0,0 +1,92 @@
# forest_viewpoints_test —— MAP-02 机位夹具校验与自动候选挑选(不加载地图)。
# godot --headless --path project --script forest_viewpoints_test.gd
extends SceneTree
const Viewpoints = preload("res://testing/forest_viewpoints.gd")
const BOUNDS := {"base": Vector2(1049600, 0), "size": Vector2i(2, 2)}
var _fail := 0
var _done := false
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
_run()
_ck(_done, "all viewpoint assertions executed")
print("forest_viewpoints_test: failures=%d" % _fail)
if _fail == 0:
print("PASS: forest_viewpoints_test")
quit(1 if _fail else 0)
func _has_error(result: Dictionary, needle: String) -> bool:
return result.errors.any(func(e: String) -> bool: return needle in e)
func _run() -> void:
# No fixture file: nothing is confirmed, every kind is missing, nothing fails.
var empty := Viewpoints.plan({}, "m", BOUNDS)
_ck(empty.errors.is_empty() and empty.viewpoints.is_empty(), "missing fixture is not an error")
_ck(empty.missing_kinds == Viewpoints.KINDS, "missing fixture leaves all four kinds unconfirmed")
_ck(Viewpoints.load_file("").ok and not Viewpoints.load_file("/nonexistent/vp.json").ok,
"empty path means no fixture; a named but absent file is an error")
var good := {"schema_version": 1, "maps": {"m": {"races": [2301, 2306], "viewpoints": [
{"id": "flat-01", "kind": "flat", "status": "confirmed", "confirmed_by": "test-env-owner",
"server_cm": [1062400, 12800], "camera_yaw_deg": 90},
{"id": "warp-01", "kind": "warp", "status": "unconfirmed", "server_cm": [1100000, 40000]},
]}}}
var planned := Viewpoints.plan(good, "m", BOUNDS)
_ck(planned.errors.is_empty(), "valid fixture accepted: %s" % [planned.errors])
_ck(planned.missing_kinds == ["slope", "dense"], "configured kinds are not reported missing")
_ck(planned.races == [2301, 2306], "races come from the fixture")
_ck(planned.viewpoints[0].local_m.is_equal_approx(Vector2(128, 128)), "server cm converts through BasePosition")
_ck(Viewpoints.to_server_cm(BOUNDS, Vector2(128, 128)) == Vector2(1062400, 12800), "local metres convert back to server cm")
var bad := {"schema_version": 2, "maps": {"m": {"races": [0], "viewpoints": [
{"id": "a", "kind": "flat", "status": "confirmed", "server_cm": [1062400, 12800]},
{"id": "a", "kind": "cave", "status": "unconfirmed", "confirmed_by": "x", "server_cm": [1062400, 12800]},
{"id": "b", "kind": "slope", "status": "maybe", "server_cm": [10, 10]},
{"id": "c", "kind": "dense", "status": "unconfirmed", "server_cm": [1.5, 2]},
]}, "password": "x"}}
var rejected := Viewpoints.plan(bad, "m", BOUNDS)
for needle in ["schema_version", "confirmed_by (role) is empty", "duplicates a", "kind must be one of",
"unconfirmed but carries confirmed_by", "status must be", "outside m", "server_cm must be",
"races must contain positive", "credentials must not be stored"]:
_ck(_has_error(rejected, needle), "invalid fixture reports: %s (got %s)" % [needle, rejected.errors])
# Candidate picking is deterministic over synthetic terrain samples.
var samples: Array = []
for x in range(0, 101, 4):
for z in range(0, 101, 4):
var p := Vector2(x, z)
var range_m := 0.02 if p.distance_to(Vector2(40, 40)) < 3.0 else 0.3
if p.distance_to(Vector2(80, 30)) < 3.0:
range_m = 1.2 # steep but walkable (0.4)
if p.distance_to(Vector2(20, 80)) < 3.0:
range_m = 6.0 # cliff (2.0) must lose to a walkable slope
samples.append({"p": p, "range_m": range_m, "span_m": 3.0, "blocked": p == Vector2(44, 40)})
var trees := PackedVector2Array()
for i in 8:
trees.append(Vector2(60, 72) + Vector2(4, 0).rotated(TAU * i / 8.0))
trees.append(Vector2(41, 41)) # tree trunk right next to the flattest point
var picked := Viewpoints.pick_candidates(samples, trees, Vector2(100, 100), Viewpoints.AUTO_KINDS)
var by_kind := {}
for c: Dictionary in picked:
by_kind[c.kind] = c
_ck(by_kind.has("flat") and by_kind.flat.range_m <= 0.3 and by_kind.flat.p.distance_to(Vector2(41, 41)) >= Viewpoints.AUTO_TREE_CLEARANCE_M,
"flat candidate keeps clear of tree trunks: %s" % [by_kind.get("flat", {})])
_ck(by_kind.has("slope") and (by_kind.slope.p as Vector2).distance_to(Vector2(80, 30)) < 3.0,
"slope candidate is the steepest walkable point, not the cliff: %s" % [by_kind.get("slope", {})])
_ck(by_kind.has("dense") and by_kind.dense.p.distance_to(Vector2(60, 72)) < Viewpoints.AUTO_DENSITY_RADIUS_M,
"dense candidate sits inside the tree cluster: %s" % [by_kind.get("dense", {})])
for a: Dictionary in picked:
for b: Dictionary in picked:
_ck(a == b or (a.p as Vector2).distance_to(b.p) >= Viewpoints.AUTO_SEPARATION_M, "candidates are separated")
_ck(a.p.x >= Viewpoints.AUTO_EDGE_MARGIN_M and a.p.y >= Viewpoints.AUTO_EDGE_MARGIN_M, "candidates avoid map edges")
_ck(not picked.any(func(c: Dictionary) -> bool: return c.kind == "warp"), "warp points are never guessed offline")
var flat_only := Viewpoints.pick_candidates([{"p": Vector2(50, 50), "range_m": 0.0, "span_m": 3.0, "blocked": false}],
PackedVector2Array(), Vector2(100, 100), Viewpoints.AUTO_KINDS)
_ck(flat_only.size() == 1 and flat_only[0].kind == "flat", "flat terrain yields no fake slope or dense candidate")
_done = true
+7
View File
@@ -29,6 +29,10 @@ var _light_states: Array[Dictionary] = []
var _bsphere_r := 0.0
var _playing := false
var _cleanup_remaining := -1.0
## MSE-defined playback length of the last play(); -1 while looping (owner-bound).
var defined_lifetime_ms := -1
## Why the node left the tree: "cleanup" when its own playback clock freed it.
var finish_reason := ""
static var _glow_tex: Texture2D
@@ -64,6 +68,7 @@ func _process(delta: float) -> void:
if _cleanup_remaining >= 0.0:
_cleanup_remaining -= maxf(delta, 0.0)
if _cleanup_remaining <= 0.0:
finish_reason = "cleanup"
queue_free()
func play(force_one_shot := false) -> void:
@@ -71,6 +76,7 @@ func play(force_one_shot := false) -> void:
# may start or delete an effect after stop/replay, or while it is paused.
_playing = true
_cleanup_remaining = maxf(_longest_life() + 0.5, 1.5) if (force_one_shot or one_shot) else -1.0
defined_lifetime_ms = roundi(_longest_life() * 1000.0) if (force_one_shot or one_shot) else -1
for state in _particle_states:
state["clock"] = 0.0
state["emission_stopped"] = false
@@ -97,6 +103,7 @@ func stop() -> void:
# Existing particles may finish their lifetime; mesh/light emission stops now.
_playing = false
_cleanup_remaining = -1.0
defined_lifetime_ms = -1
for e in _emitters:
e.emitting = false
for mesh in _mesh_nodes:
+24
View File
@@ -6,12 +6,21 @@
# var pv := fxr.spawn_at("hit_spark", world, global_pos)
extends RefCounted
## Lifecycle boundaries of every effect this registry creates. lifetime_ms is the
## MSE-defined length (-1 = looping, lives as long as its owner). fx_finished is
## emitted when the node actually leaves the tree; reason "cleanup" means its own
## playback clock freed it, "removed" means the owner/scene removed it earlier.
signal fx_spawned(effect: String, fx_id: int, lifetime_ms: int)
signal fx_finished(effect: String, fx_id: int, lifetime_ms: int, elapsed_ms: int, reason: String)
const Mse = preload("res://fx/mse.gd")
const EffectPlayer = preload("res://fx/effect_player.gd")
var assets_root := ""
var _path_cache := {} # name -> abs .mse path ("" = not found)
var _spec_cache := {} # abs path -> parsed spec
# Process-wide so ids stay unique when GameScene rebuilds its registry.
static var _next_fx_id := 0
func setup(assets: String) -> void:
assets_root = assets
@@ -89,8 +98,23 @@ func spawn(name: String, parent: Node3D, one_shot := true) -> Node3D:
fx.build(s, assets_root)
parent.add_child(fx)
fx.play(one_shot)
_track(fx, name)
return fx
func _track(fx: Node3D, name: String) -> void:
_next_fx_id += 1
var fx_id := _next_fx_id
var lifetime_ms: int = fx.defined_lifetime_ms
fx.tree_exiting.connect(_on_fx_exiting.bind(fx, name, fx_id, lifetime_ms, Time.get_ticks_msec()),
CONNECT_ONE_SHOT)
fx_spawned.emit(name, fx_id, lifetime_ms)
func _on_fx_exiting(fx: Node3D, name: String, fx_id: int, lifetime_ms: int, spawned_ms: int) -> void:
var reason := "removed"
if is_instance_valid(fx) and not String(fx.finish_reason).is_empty():
reason = fx.finish_reason
fx_finished.emit(name, fx_id, lifetime_ms, Time.get_ticks_msec() - spawned_ms, reason)
# 在世界某点播一次性
func spawn_at(name: String, world_parent: Node3D, global_pos: Vector3, one_shot := true) -> Node3D:
var fx := spawn(name, world_parent, one_shot)
+1 -1
View File
@@ -144,7 +144,7 @@ func _pinch_dist() -> float:
return (_touches[ks[0]] as Vector2).distance_to(_touches[ks[1]] as Vector2)
func heading() -> float:
# 相机看向的水平方向(供角色移动「相对相机」用)
# 相机方位角:相机在 head + (sin yaw, cos yaw) 一侧,视线水平前方是 -(sin yaw, cos yaw)。
return yaw
func is_event_locked() -> bool:
+9 -1
View File
@@ -273,6 +273,10 @@ func setup(m2client: Node, assets_root: String,
# 特效注册表(P5)—— GC_SPECIFIC_EFFECT(.mse 路径) / GC_SPECIAL_EFFECT(内建 id) 在实体上播
fx = EffectRegistry.new()
fx.setup(_assets)
# §3.7 __ProcessDataAttackSuccess:命中特效(m_dwBattleHitEffectID)挂在实体挂载点下播
if net_play:
net_play.fx = fx
net_play.fx_parent = _mount
# 技能(P6)—— K 键技能窗,数字键 1-4 → 快捷栏 0-3 / F1-F4 → 4-7 / Ctrl+数字 = 表情,+ 技能特效表
if assets_root != "":
@@ -280,6 +284,10 @@ func setup(m2client: Node, assets_root: String,
for lang in ["en", "common"]:
if skill_table.load_file(assets_root.path_join("locale/locale/%s/skilldesc.txt" % lang)):
break
# LoadLocaleDataRegisterSkillDesc 之后 RegisterSkillTable(技能射程 TARGET_RANGE)。
for lang in ["en", "common"]:
if skill_table.load_table(assets_root.path_join("locale/locale/%s/skilltable.txt" % lang)):
break
skill_fx = SkillFx.new()
skill_fx.setup(fx, skill_table)
skills = SkillUI.new()
@@ -1287,7 +1295,7 @@ func get_playable_context() -> Dictionary:
return {"scene_ready": _model_built and _map_loaded(), "map_path": map_path,
"net_play": net_play, "net_world": net_world, "world": world,
"player": player, "pc": pc, "hud": hud, "quickbar": quickbar,
"ground_items": ground_items, "skill_fx": skill_fx}
"ground_items": ground_items, "skill_fx": skill_fx, "fx": fx}
var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试)
var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性)
+4
View File
@@ -71,6 +71,10 @@ func _run() -> void:
_ck(gs.pc != null, "player_controller built")
_ck(gs.net_world != null, "net_world built")
_ck(gs.net_play != null, "net_play built")
# §3.7 命中特效 m_dwBattleHitEffectIDnet_play 用场景的特效注册表,挂在实体挂载点下
_ck(gs.net_play != null and gs.fx != null and gs.net_play.fx == gs.fx \
and gs.net_play.fx_parent == gs.get_node_or_null("Entities"),
"net_play.fx / fx_parent wired to the scene effect registry + entity mount")
_ck(gs.hud != null, "hud built")
_ck(gs.get_node_or_null("AppLifecycle") == null,
"GameScene does not create a second lifecycle coordinator")
+3
View File
@@ -24,6 +24,9 @@ func run() -> void:
for motion in ["wait", "walk", "run", "attack", "damage", "dead"]:
for i in 2:
OS.set_environment("MTGODOT_GPUSKIN", str(i))
# damage has weighted variants (damage / damage_1, CRaceData::GetMotionKey);
# the same seed makes the CPU and GPU views pick the same clip.
seed(race)
views[i].set_anim_state(motion)
views[i].anim.set("loop", false)
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0]:
+160
View File
@@ -0,0 +1,160 @@
# HitCollision —— CLIENT-GAP §3.5 修改 2`CActorInstance::__NormalAttackProcess` 用到的碰撞几何,
# 逐行移植(单位 = Metin2 cm,Z 为高度):
# IntersectLineSegments / FindNearestPointOnLineSegment / FindNearestPointOfParallelLineSegments /
# AdjustNearestPointsEterLib/lineintersect_utils.cppMY_EPSILON 0.1
# DetectCollisionDynamicZCylinderVSDynamicZCylinderGameLib/GameUtil.cpp
# .msm AttachingData CollisionType 3 = m_DefendingPointInstanceList 的防御球
extends RefCounted
const MY_EPSILON := 0.1
const COLLISION_TYPE_DEFENDING := 3 # NRaceData::COLLISION_TYPE_DEFENDING
const HUGE_RACE_VNUM := 2493 # IS_HUGE_RACEInstanceBase.cpp
static var _msm_cache := {}
# FindNearestPointOnLineSegment|L|² < ε² 时 Nearest = A1parameter 保持调用方原值。
# 返回 [Nearest, parameter]。
static func _nearest_on_segment(a1: Vector3, l: Vector3, b: Vector3, parameter: float) -> Array:
var d := l.length_squared()
if d < MY_EPSILON * MY_EPSILON:
return [a1, parameter]
var p := clampf((b - a1).dot(l) / d, 0.0, 1.0)
return [a1 + p * l, p]
static func _out_of_range(a: float) -> bool:
return a < 0.0 or a > 1.0
# FindNearestPointOfParallelLineSegmentss[] 已被 FindNearestPointOnLineSegment 夹到 [0,1]
# 所以前两个分支在出货代码里实际走不到;照抄保留)。
static func _parallel(a1: Vector3, a2: Vector3, la: Vector3, b1: Vector3, b2: Vector3, lb: Vector3) -> Array:
var r0 := _nearest_on_segment(a1, la, b1, 0.0)
var out_a: Vector3 = r0[0]
var s0: float = r0[1]
var s1: float = _nearest_on_segment(a1, la, b2, 0.0)[1]
var out_b := Vector3.ZERO
if s0 < 0.0 and s1 < 0.0:
out_a = a1
out_b = b2 if s0 < s1 else b1
elif s0 > 1.0 and s1 > 1.0:
out_a = a2
out_b = b1 if s0 < s1 else b2
else:
var temp := 0.5 * (clampf(s0, 0.0, 1.0) + clampf(s1, 0.0, 1.0))
out_a = a1 + temp * la
out_b = _nearest_on_segment(b1, lb, out_a, temp)[0]
return [out_a, out_b]
static func _adjust(a1: Vector3, la: Vector3, b1: Vector3, lb: Vector3, s: float, t: float) -> Array:
var out_a := a1 + s * la
var out_b := b1 + t * lb
var r: Array
if _out_of_range(s) and _out_of_range(t):
s = clampf(s, 0.0, 1.0)
out_a = a1 + s * la
r = _nearest_on_segment(b1, lb, out_a, t)
out_b = r[0]
t = r[1]
if _out_of_range(t):
t = clampf(t, 0.0, 1.0)
out_b = b1 + t * lb
r = _nearest_on_segment(a1, la, out_b, s)
out_a = r[0]
s = r[1]
r = _nearest_on_segment(b1, lb, out_a, t)
out_b = r[0]
elif _out_of_range(s):
s = clampf(s, 0.0, 1.0)
out_a = a1 + s * la
out_b = _nearest_on_segment(b1, lb, out_a, t)[0]
elif _out_of_range(t):
t = clampf(t, 0.0, 1.0)
out_b = b1 + t * lb
out_a = _nearest_on_segment(a1, la, out_b, s)[0]
return [out_a, out_b]
# 返回 [OutA, OutB]:两段上的最近点对。
static func intersect_line_segments(a1: Vector3, a2: Vector3, b1: Vector3, b2: Vector3) -> Array:
var la := a2 - a1
var lb := b2 - b1
var l11 := la.length_squared()
var l22 := lb.length_squared()
var eps2 := MY_EPSILON * MY_EPSILON
if l11 < eps2:
return [a1, _nearest_on_segment(b1, lb, a1, 0.0)[0]]
elif l22 < eps2:
return [_nearest_on_segment(a1, la, b1, 0.0)[0], b1]
var ab := b1 - a1
var l12 := -la.dot(lb)
var det_l := l11 * l22 - l12 * l12
if absf(det_l) < MY_EPSILON:
return _parallel(a1, a2, la, b1, b2, lb)
var ra := la.dot(ab)
# 出货代码是 +dot(Lb, AB)(旁注写的是负号)——两段都在动时 t 会反号,1:1 保留。
var rb := lb.dot(ab)
var t := (l11 * rb - ra * l12) / det_l
var s := (ra - l12 * t) / l11
if _out_of_range(s) or _out_of_range(t):
return _adjust(a1, la, b1, lb, s, t)
return [a1 + s * la, b1 + t * lb]
# DetectCollisionDynamicZCylinderVSDynamicZCylinder:两个扫掠球都压到 z=0(高度不参与),
# 各自 AABB(按自身半径外扩)不相交直接 false,否则比较两段最近点距离与 r1+r2。
static func detect_z_cylinder(c1_last: Vector3, c1_pos: Vector3, r1: float,
c2_last: Vector3, c2_pos: Vector3, r2: float) -> bool:
c1_last.z = 0.0
c1_pos.z = 0.0
c2_last.z = 0.0
c2_pos.z = 0.0
var r := r1 + r2
var mi1 := Vector3(minf(c1_last.x, c1_pos.x), minf(c1_last.y, c1_pos.y), minf(c1_last.z, c1_pos.z)) - Vector3.ONE * r1
var mi2 := Vector3(maxf(c1_last.x, c1_pos.x), maxf(c1_last.y, c1_pos.y), maxf(c1_last.z, c1_pos.z)) + Vector3.ONE * r1
var mi3 := Vector3(minf(c2_last.x, c2_pos.x), minf(c2_last.y, c2_pos.y), minf(c2_last.z, c2_pos.z)) - Vector3.ONE * r2
var mi4 := Vector3(maxf(c2_last.x, c2_pos.x), maxf(c2_last.y, c2_pos.y), maxf(c2_last.z, c2_pos.z)) + Vector3.ONE * r2
if mi4.x < mi1.x or mi2.x < mi3.x:
return false
if mi4.y < mi1.y or mi2.y < mi3.y:
return false
if mi4.z < mi1.z or mi2.z < mi3.z:
return false
var o := intersect_line_segments(c1_last, c1_pos, c2_last, c2_pos)
return (Vector3(o[0]) - Vector3(o[1])).length_squared() <= r * r
# IS_HUGE_RACEInstanceBase.cpp):出货表只有 2493。
static func is_huge_race(race: int) -> bool:
return race == HUGE_RACE_VNUM
# 解析 .msm 的防御球:[{radius, pos: Vector3(cm, 模型本地), bone}]。bone 非空 = isAttaching
# 挂骨骼(本端按模型本地坐标近似,seam)。缺文件 -> []。按路径缓存。
static func parse_msm_defending(path: String) -> Array:
if _msm_cache.has(path):
return _msm_cache[path]
var out := []
if FileAccess.file_exists(path):
var f := FileAccess.open(path, FileAccess.READ)
var ctype := -1
var bone := ""
var attaching := false
var radius := 0.0
while f and not f.eof_reached():
var parts := f.get_line().strip_edges().replace("\t", " ").split(" ", false)
if parts.is_empty():
continue
var key := String(parts[0]).to_lower()
if key == "group" and parts.size() > 1 and String(parts[1]).to_lower().begins_with("attachingdata"):
ctype = -1
bone = ""
attaching = false
elif key == "collisiontype" and parts.size() > 1:
ctype = int(parts[1])
elif key == "isattaching" and parts.size() > 1:
attaching = int(parts[1]) != 0
elif key == "attachingbonename" and parts.size() > 1:
bone = String(parts[1]).trim_prefix("\"").trim_suffix("\"")
elif key == "radius" and parts.size() > 1:
radius = float(parts[1])
elif key == "position" and parts.size() > 3 and ctype == COLLISION_TYPE_DEFENDING:
out.append({"radius": radius,
"pos": Vector3(float(parts[1]), float(parts[2]), float(parts[3])),
"bone": bone if attaching else ""})
_msm_cache[path] = out
return out
+78
View File
@@ -0,0 +1,78 @@
# hit_collision_test —— §3.5__NormalAttackProcess 的碰撞几何 headless 自检。
# IntersectLineSegmentsEterLib/lineintersect_utils.cpp
# DetectCollisionDynamicZCylinderVSDynamicZCylinderGameLib/GameUtil.cpp
# .msm AttachingData CollisionType 3 = m_DefendingPointInstanceList
# godot --headless --path project --script hit_collision_test.gd
extends SceneTree
const HitCollision = preload("res://hit_collision.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: hit_collision_test (segments / z-cylinder / msm defending)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _near(a: Vector3, b: Vector3) -> bool:
return a.distance_to(b) < 0.01
func _run() -> void:
# --- IntersectLineSegments ---
# 静止受击方(L22 < ε²):OutB = B1OutA = A 上离 B1 最近点。
var r: Array = HitCollision.intersect_line_segments(
Vector3(-100, 0, 0), Vector3(100, 0, 0), Vector3(0, 50, 0), Vector3(0, 50, 0))
_ck(_near(r[0], Vector3.ZERO) and _near(r[1], Vector3(0, 50, 0)), "stationary B -> nearest on A = (0,0,0)")
# 退化 AL11 < ε²)
r = HitCollision.intersect_line_segments(
Vector3(10, 10, 0), Vector3(10, 10, 0), Vector3(0, 0, 0), Vector3(100, 0, 0))
_ck(_near(r[0], Vector3(10, 10, 0)) and _near(r[1], Vector3(10, 0, 0)), "degenerate A -> OutB (10,0,0)")
# 平行(|DetL| < ε):两段 clamp 参数取中点
r = HitCollision.intersect_line_segments(
Vector3(0, 0, 0), Vector3(100, 0, 0), Vector3(0, 50, 0), Vector3(100, 50, 0))
_ck(_near(r[0], Vector3(50, 0, 0)) and _near(r[1], Vector3(50, 50, 0)), "parallel -> midpoints (50,0)/(50,50)")
# 一般情形,s/t 双越界 -> AdjustNearestPoints
r = HitCollision.intersect_line_segments(
Vector3(0, 0, 0), Vector3(100, 0, 0), Vector3(200, -50, 0), Vector3(200, 50, 0))
_ck(_near(r[0], Vector3(100, 0, 0)) and _near(r[1], Vector3(200, 0, 0)), "both out of range -> (100,0)/(200,0)")
# 参考端 rb = +dot(Lb, AB)(注释里是负号):两条运动段十字相交时 t 被算成 -0.5,
# 经 AdjustNearestPoints 夹到 B1。1:1 保留这个出货行为。
r = HitCollision.intersect_line_segments(
Vector3(-100, 0, 0), Vector3(100, 0, 0), Vector3(0, -100, 0), Vector3(0, 100, 0))
_ck(_near(r[0], Vector3.ZERO) and _near(r[1], Vector3(0, -100, 0)),
"shipped rb sign: crossing moving segments -> OutB clamps to B1, got %s / %s" % [r[0], r[1]])
# --- DetectCollisionDynamicZCylinderVSDynamicZCylinder ---
# 攻击球扫过静止受击球:只看扫掠段,不看端点;z(高度)清零。
_ck(HitCollision.detect_z_cylinder(Vector3(-200, 0, 500), Vector3(200, 0, 500), 20.0,
Vector3(0, 60, 0), Vector3(0, 60, 0), 70.0), "sweep passes 60cm from victim, r 20+70 -> hit (height ignored)")
_ck(not HitCollision.detect_z_cylinder(Vector3(-200, 0, 0), Vector3(200, 0, 0), 20.0,
Vector3(0, 100, 0), Vector3(0, 100, 0), 70.0), "sweep passes 100cm away -> no hit")
_ck(not HitCollision.detect_z_cylinder(Vector3(-200, 0, 0), Vector3(200, 0, 0), 20.0,
Vector3(400, 0, 0), Vector3(400, 0, 0), 70.0), "AABB reject -> no hit")
_ck(HitCollision.detect_z_cylinder(Vector3(0, 0, 0), Vector3(0, 0, 0), 20.0,
Vector3(80, 0, 900), Vector3(80, 0, 900), 70.0), "static spheres 80cm apart, r sum 90 -> hit")
# --- .msm 防御球(CollisionType 3),CollisionType 1 = body 不算 ---
var assets := AssetRoot.path()
var wolf := assets.path_join("Monster/ymir work/monster/wolf/wolf_blue.msm")
var ds: Array = HitCollision.parse_msm_defending(wolf)
_ck(ds.size() == 1, "wolf_blue.msm -> 1 defending sphere, got %d" % ds.size())
if ds.size() == 1:
_ck(is_equal_approx(float(ds[0].radius), 90.0) and _near(ds[0].pos, Vector3(0, -15, 80)),
"wolf defending sphere r90 (0,-15,80), got %s" % [ds[0]])
var war := assets.path_join("root/msm/warrior_m.msm")
ds = HitCollision.parse_msm_defending(war)
_ck(ds.size() >= 1 and is_equal_approx(float(ds[0].radius), 70.0) and _near(ds[0].pos, Vector3(0, 0, 100)),
"warrior_m.msm defending sphere r70 (0,0,100), got %s" % [ds])
_ck(HitCollision.parse_msm_defending(assets.path_join("no/such.msm")).is_empty(), "missing msm -> []")
# IS_HUGE_RACEInstanceBase.cpp
_ck(HitCollision.is_huge_race(2493) and not HitCollision.is_huge_race(101), "IS_HUGE_RACE: only 2493")
+254
View File
@@ -0,0 +1,254 @@
# hit_view_test —— CLIENT-GAP §3.7 受击方 / 攻击方视图(PlayerView / MobViewheadless 自检:
# __HitGood / __HitGreate / __HitStone 动作链(ActorInstanceBattle.cpp:769-890)、__Shake、
# InsertDelay(动作冻结)、攻击动作按 (motion mode, motion index, fSpeedRatio) 绑定、
# .msm 防御球、motion_bound__SetMotion 尾给 net_play 的钩子)。
# godot --headless --path project --script hit_view_test.gd
extends SceneTree
const PlayerView = preload("res://ui/player_view.gd")
const MobView = preload("res://ui/mob_view.gd")
# Metin2AnimPlayer 桩:只记属性(anim_path 每次赋值都记,验证「同路径先清再设」)
class FakeAnim extends Node:
signal playback_finished
var sets := []
var anim_path := "":
set(v):
anim_path = v
sets.append(v)
var loop := true
var time_scale := 1.0
var playing := true
var blend_time := 0.15
func get_motion_data() -> Dictionary: return {}
const MSM := "ScriptType CharacterModelScript\nGroup AttachingData\n{\n\tGroup AttachingData00\n\t{\n\t\tAttachingDataType 0\n\t\tIsAttaching 0\n\t\tGroup CollisionData\n\t\t{\n\t\t\tCollisionType 3\n\t\t\tGroup SphereData\n\t\t\t{\n\t\t\t\tSphereDataCount 1\n\t\t\t\tGroup SphereData00\n\t\t\t\t{\n\t\t\t\t\tRadius %.1f\n\t\t\t\t\tPosition 0.0 0.0 %.1f\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _touch(p: String, body := "") -> void:
DirAccess.make_dir_recursive_absolute(p.get_base_dir())
var f := FileAccess.open(p, FileAccess.WRITE)
f.store_string(body)
f.close()
var _done := false
func _init() -> void:
await _run()
_ck(_done, "test body ran to completion (a SCRIPT ERROR aborted _run)")
if _fail == 0:
print("PASS: hit_view_test (hit reactions / shake / delay / attack motion / defending spheres)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var root := ProjectSettings.globalize_path("user://hit_view_test")
var gen := root.path_join("general")
for n in ["wait", "run", "attack", "attack_1", "damage", "damage_1", "damage_2", "damage_3",
"damage_flying", "falling_stand", "back_damage_flying", "back_falling_stand"]:
_touch(gen.path_join(n + ".msa"))
for n in ["combo_01", "combo_02", "attack"]:
_touch(root.path_join("onehand_sword/%s.msa" % n))
_touch(root.path_join("root/msm/warrior_m.msm"), MSM % [50.0, 90.0])
_touch(root.path_join("root/msm/warrior_w.msm"), MSM % [40.0, 80.0])
var gen_attack := [gen.path_join("attack.msa"), gen.path_join("attack_1.msa")]
# --- PlayerView ---------------------------------------------------------
var pv: Node3D = PlayerView.new()
get_root().add_child(pv)
var model := Node3D.new()
pv.add_child(model)
pv.model = model
var anim := FakeAnim.new()
pv.add_child(anim)
pv.anim = anim
anim.playback_finished.connect(pv._on_playback_finished)
pv.motion_dir = gen
pv._assets_root = root
pv._race = 0
var bound := []
pv.motion_bound.connect(func(s: String): bound.append(s))
await process_frame
pv.set_anim_state("wait")
_ck(anim.anim_path == gen.path_join("wait.msa") and bound == ["wait"],
"set_anim_state binds wait.msa -> motion_bound(wait), got %s" % [bound])
# __SetMotion(MAKE_MOTION_KEY(mode, index), fSpeedRatio = m_fAtkSpd)
var c1 := root.path_join("onehand_sword/combo_01.msa")
pv.play_attack_motion(2, 14, 1.5)
_ck(anim.anim_path == c1 and not anim.loop and is_equal_approx(anim.time_scale, 1.5) \
and bound.back() == "attack",
"ONEHAND_SWORD COMBO_ATTACK_1 -> combo_01.msa one-shot at speed 1.5, got %s" % anim.anim_path)
anim.sets.clear()
pv.play_attack_motion(2, 14, 1.5)
_ck(anim.sets == ["", c1], "same seg again -> re-bound from frame 0, got %s" % [anim.sets])
pv.play_attack_motion(2, 15, 1.0)
_ck(anim.anim_path == root.path_join("onehand_sword/combo_02.msa"), "index 15 -> combo_02.msa")
pv.play_attack_motion(1, 13, 1.0)
_ck(anim.anim_path in gen_attack, "GENERAL NORMAL_ATTACK -> attack / attack_1, got %s" % anim.anim_path)
pv.play_attack_motion(1, 14, 1.0)
_ck(anim.anim_path in gen_attack, "GENERAL COMBO_ATTACK_1 -> attack / attack_1, got %s" % anim.anim_path)
pv.play_attack_motion(6, 14, 1.0)
_ck(anim.anim_path in gen_attack, "missing mode folder -> general attack fallback, got %s" % anim.anim_path)
_ck(not pv.is_in_hit_reaction(), "attack motion is not a hit reaction")
# CGraphicThingInstance::InsertDelay:动作冻结 fDelay 秒后恢复 fSpeedRatio
pv.play_attack_motion(2, 14, 1.5)
pv.insert_delay(0.1)
_ck(anim.time_scale == 0.0, "InsertDelay -> motion frozen")
pv._process(0.05)
_ck(anim.time_scale == 0.0, "inside the delay -> still frozen")
pv._process(0.06)
_ck(is_equal_approx(anim.time_scale, 1.5), "delay over -> fSpeedRatio restored, got %s" % anim.time_scale)
# isLock(攻击动作中):__HitGood 只 __Shake(100)
var atk_path: String = anim.anim_path
pv.hit_good(-1.0, false)
_ck(anim.anim_path == atk_path and pv._hit.shake_left > 0.0, "isLock (attack) -> shake only, no damage motion")
pv._process(0.05)
var off: Vector3 = model.position
_ck(absf(off.x) <= 0.09 and absf(off.y) <= 0.09 and absf(off.z) <= 0.09,
"ShakeProcess offset within rand()%%10 cm, got %s" % off)
pv._process(0.2)
_ck(model.position == Vector3.ZERO, "shake over -> model back on its ground offset, got %s" % model.position)
# __HitGoodfScalar < 0 -> NAME_DAMAGE>= 0 -> NAME_DAMAGE_BACK;之后 PushLoopMotion(WAIT)
pv.set_anim_state("wait")
pv._hit.shake_left = 0.0
pv.hit_good(-1.0, false)
_ck(anim.anim_path in [gen.path_join("damage.msa"), gen.path_join("damage_1.msa")] and not anim.loop \
and pv.is_in_hit_reaction() and bound.back() == "damage",
"fScalar < 0 -> NAME_DAMAGE (damage / damage_1), got %s" % anim.anim_path)
anim.playback_finished.emit()
_ck(anim.anim_path == gen.path_join("wait.msa") and not pv.is_in_hit_reaction(), "damage done -> WAIT")
pv.hit_good(1.0, false)
_ck(anim.anim_path in [gen.path_join("damage_2.msa"), gen.path_join("damage_3.msa")],
"fScalar >= 0 -> NAME_DAMAGE_BACK (damage_2 / damage_3), got %s" % anim.anim_path)
anim.playback_finished.emit()
pv._process(0.2)
pv._hit.shake_left = 0.0
var before: String = anim.anim_path
pv.hit_good(-1.0, true)
_ck(anim.anim_path == before and pv._hit.shake_left == 0.0, "stunned -> Die() branch: no shake / damage motion")
# __HitGreateDAMAGE_FLYING -> STAND_UP -> 回循环动作;击倒 / 起身中不再受 Good / Greate
pv.set_anim_state("run")
pv.hit_greate(-1.0, false)
_ck(anim.anim_path == gen.path_join("damage_flying.msa") and pv.is_in_hit_reaction(),
"GREAT fScalar < 0 -> NAME_DAMAGE_FLYING, got %s" % anim.anim_path)
before = anim.anim_path
pv.hit_good(-1.0, false)
_ck(anim.anim_path == before, "IsKnockDown -> __HitGood returns")
pv.hit_greate(1.0, false)
_ck(anim.anim_path == before, "IsKnockDown -> __HitGreate returns")
anim.playback_finished.emit()
_ck(anim.anim_path == gen.path_join("falling_stand.msa"), "knockdown done -> PushOnceMotion(NAME_STAND_UP)")
pv.hit_greate(-1.0, false)
_ck(anim.anim_path == gen.path_join("falling_stand.msa"), "__IsStandUpMotion -> __HitGreate returns")
anim.playback_finished.emit()
_ck(anim.anim_path == gen.path_join("run.msa") and not pv.is_in_hit_reaction(),
"stand up done -> loop motion resumes (run), got %s" % anim.anim_path)
pv.hit_greate(1.0, false)
_ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "GREAT fScalar >= 0 -> NAME_DAMAGE_FLYING_BACK")
anim.playback_finished.emit()
_ck(anim.anim_path == gen.path_join("back_falling_stand.msa"), "-> NAME_STAND_UP_BACK")
anim.playback_finished.emit()
pv.hit_greate(1.0, true)
_ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "stunned GREAT -> DAMAGE_FLYING_BACK")
anim.playback_finished.emit()
_ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "stunned knockdown -> m_isRealDead: no stand up")
# IsUsingSkill -> 只 __Shake
pv.set_anim_state("skill")
pv._hit.shake_left = 0.0
before = anim.anim_path
pv.hit_greate(-1.0, false)
_ck(anim.anim_path == before and pv._hit.shake_left > 0.0, "IsUsingSkill -> __HitGreate shakes only")
pv.set_anim_state("wait")
pv._hit.shake_left = 0.0
pv.hit_stone(false)
_ck(pv._hit.shake_left > 0.0 and anim.anim_path == gen.path_join("wait.msa"), "__HitStone -> shake only")
pv._hit.shake_left = 0.0
pv.hit_stone(true)
_ck(pv._hit.shake_left == 0.0, "stunned stone -> Die() branch, no shake")
# .msm 防御球:root/msm/<class>_<m|w>.msm
var sp: Array = pv.get_defending_spheres()
_ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 50.0) \
and Vector3(sp[0]["pos"]).is_equal_approx(Vector3(0, 0, 90)),
"race 0 -> warrior_m.msm defending sphere, got %s" % [sp])
pv._race = 4
sp = pv.get_defending_spheres()
_ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 40.0), "race 4 -> warrior_w.msm, got %s" % [sp])
# --- MobView ------------------------------------------------------------
var mdir := root.path_join("mob")
var files := {"WAIT": "00.msa", "RUN": "03.msa", "NORMAL_ATTACK": "20.msa", "FRONT_DAMAGE": "30.msa",
"FRONT_KNOCKDOWN": "32.msa", "FRONT_STANDUP": "33.msa", "BACK_DAMAGE": "34.msa", "BACK_KNOCKDOWN": "35.msa"}
var mv: Node3D = MobView.new()
get_root().add_child(mv)
var mmodel := Node3D.new()
mv.add_child(mmodel)
mv.model = mmodel
var manim := FakeAnim.new()
mv.add_child(manim)
mv.anim = manim
manim.playback_finished.connect(mv._on_playback_finished)
mv._dir = mdir
mv._mesh_stem = "mobx"
for k in files:
_touch(mdir.path_join(files[k]))
mv._motions[k] = mdir.path_join(files[k])
_touch(mdir.path_join("mobx.msm"), MSM % [60.0, 120.0])
var mbound := []
mv.motion_bound.connect(func(s: String): mbound.append(s))
var M := func(n: String) -> String: return mdir.path_join(files[n])
mv.set_anim_state("wait")
_ck(manim.anim_path == M.call("WAIT") and mbound == ["wait"], "mob set_anim_state -> motion_bound(wait)")
mv.hit_good(1.0, false)
_ck(manim.anim_path == M.call("BACK_DAMAGE") and mv.is_in_hit_reaction(), "mob fScalar >= 0 -> BACK_DAMAGE")
manim.playback_finished.emit()
_ck(manim.anim_path == M.call("WAIT") and not mv.is_in_hit_reaction(), "mob damage done -> WAIT")
mv.hit_good(-1.0, false)
_ck(manim.anim_path == M.call("FRONT_DAMAGE"), "mob fScalar < 0 -> FRONT_DAMAGE")
manim.playback_finished.emit()
mv._motions.erase("BACK_DAMAGE")
mv.hit_good(1.0, false)
_ck(manim.anim_path == M.call("FRONT_DAMAGE"), "no DAMAGE_BACK motion -> InterceptOnceMotion(NAME_DAMAGE)")
manim.playback_finished.emit()
mv.hit_greate(1.0, false)
_ck(manim.anim_path == M.call("BACK_KNOCKDOWN"), "mob GREAT fScalar >= 0 -> BACK_KNOCKDOWN")
manim.playback_finished.emit()
_ck(manim.anim_path == M.call("WAIT"), "no BACK_STANDUP -> PushOnceMotion fails -> WAIT, got %s" % manim.anim_path)
mv.hit_greate(-1.0, false)
_ck(manim.anim_path == M.call("FRONT_KNOCKDOWN"), "mob GREAT fScalar < 0 -> FRONT_KNOCKDOWN")
manim.playback_finished.emit()
_ck(manim.anim_path == M.call("FRONT_STANDUP"), "-> FRONT_STANDUP")
manim.playback_finished.emit()
_ck(manim.anim_path == M.call("WAIT"), "-> WAIT")
mv._motions.erase("FRONT_KNOCKDOWN")
mv._motions.erase("BACK_KNOCKDOWN")
before = manim.anim_path
mv.hit_greate(1.0, false)
_ck(manim.anim_path == before, "no knockdown motions -> no motion change")
mv.set_anim_state("attack")
mv.hit_good(-1.0, false)
_ck(manim.anim_path == M.call("NORMAL_ATTACK"), "mob attacking -> isLock, no damage motion")
mv.insert_delay(0.05)
_ck(manim.time_scale == 0.0, "mob InsertDelay -> frozen")
mv._process(0.06)
_ck(manim.time_scale == 1.0, "mob delay over -> speed restored")
sp = mv.get_defending_spheres()
_ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 60.0), "mob -> <dir>/<stem>.msm, got %s" % [sp])
pv.queue_free()
mv.queue_free()
_done = true
+20 -1
View File
@@ -4,6 +4,7 @@
extends SceneTree
const GameScene = preload("res://game_scene.gd")
const UiManager = preload("res://ui/ui_manager.gd")
class FakeClient extends Node:
var emotes := []
@@ -73,7 +74,7 @@ func _key(code: Key, pressed := true, ctrl := false, meta := false) -> InputEven
return event
func _init() -> void:
_run()
await _run()
if _fail == 0:
print("PASS: input_key_test (ClientVS22 desktop key map)")
quit(0)
@@ -143,3 +144,21 @@ func _run() -> void:
gs._unhandled_input(_key(KEY_J, true, true))
_ck(client.commands == ["/user_horse_ride", "/user_horse_back", "/unmount"],
"Ctrl/Command horse commands follow reference")
# CWindowManager::RunKeyDown:只有 LockWindow(模态)或返回 True 的 ActiveWindow(输入框)
# 截键;普通打开的窗口(技能窗 / 背包)不吞键,1-4 / F1-F4 照样落到 game.py 快捷栏。
var ui_manager: CanvasLayer = UiManager.new()
get_root().add_child(ui_manager)
await process_frame
gs.ui = ui_manager
var skill_window := Panel.new()
ui_manager.open(skill_window)
qb.activated.clear()
gs._unhandled_input(_key(KEY_2))
gs._unhandled_input(_key(KEY_F2))
_ck(qb.activated == [1, 5], "non-modal window open keeps number/F-key quick slots: %s" % [qb.activated])
var confirm := Panel.new()
ui_manager.open(confirm, true)
gs._unhandled_input(_key(KEY_3))
_ck(qb.activated == [1, 5], "modal lock window still swallows quick slot keys")
gs.ui = null
+83
View File
@@ -5,8 +5,11 @@ extends SceneTree
const MobView = preload("res://ui/mob_view.gd")
const Audio = preload("res://audio.gd")
const MsaMotion = preload("res://ui/msa_motion.gd")
var _fail := 0
var _resolution_done := false
var _move_speed_done := false
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
@@ -21,7 +24,87 @@ func _init() -> void:
printerr("%d check(s) failed" % _fail)
quit(1)
class FakeAnim:
extends Node
var anim_path := ""
var loop := false
var time_scale := 1.0
# 纯解析:不需要扩展和资产。旧客户端 CRaceData::GetMotionKey 找不到动作时
# SetLoopMotion/InterceptMotion 直接返回、保留当前动作;不能静默换成 WAIT。
func _check_motion_resolution() -> void:
var mv: Node3D = MobView.new()
mv._dir = "res://__no_such_mob_dir__"
mv._motions = {"WAIT": "wait.msa", "NORMAL_ATTACK": "attack.msa", "FRONT_DEAD": "dead.msa"}
var dead: Dictionary = mv.resolve_motion("dead")
_ck(dead.get("requested_state") == "dead" and dead.get("motion") == "FRONT_DEAD" \
and dead.get("path") == "dead.msa" and dead.get("fallback_reason") == "", "dead -> FRONT_DEAD 无回退")
var run: Dictionary = mv.resolve_motion("run")
_ck(run.get("path") == "" and run.get("motion") == "" \
and run.get("fallback_reason") == "reference_keep_current_motion", "缺 RUN/WALK -> 保留当前动作(%s" % run)
_ck(mv.get_motion_path("run") == "", "缺 RUN/WALK 时 get_motion_path 不返回 WAIT")
mv._motions = {"WAIT": "wait.msa"}
_ck(mv.get_motion_path("dead") == "", "缺死亡动作时不能解析成 WAIT")
mv._motions = {"WAIT": "wait.msa", "WALK": "walk.msa"}
var alias: Dictionary = mv.resolve_motion("run")
_ck(alias.get("path") == "walk.msa" and alias.get("fallback_reason") == "client_alias:WALK", "run -> WALK 标注别名回退")
mv._motions = {"WAIT": "wait.msa"}
var anim := FakeAnim.new()
mv.anim = anim
mv.set_anim_state("wait")
mv.set_anim_state("run")
_ck(anim.anim_path == "wait.msa" and anim.loop, "缺 run 时保留当前 WAIT 播放")
_ck(mv.last_motion_resolution().get("fallback_reason") == "reference_keep_current_motion", "记录最后一次解析的回退原因")
mv.anim = null
anim.free()
mv.free()
_resolution_done = true
# CActorInstance::Move:走 / 跑按 m_fMovSpd = movSpd/100 播放;根运动速度
# = |.msa Accumulation| / MotionDurationwolf 03.msa255.45 / 0.6 = 425.75 cm/s)。
func _write_msa(path: String, duration: float, ay: float) -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("ScriptType MotionData\n\nMotionFileName \"x.gr2\"\n" +
"MotionDuration %f\nAccumulation 0.00\t%.2f\t0.00\n" % [duration, ay])
f.close()
func _check_move_speed() -> void:
var walk_p := "user://__mob_view_test_walk.msa"
var run_p := "user://__mob_view_test_run.msa"
_write_msa(walk_p, 0.866667, -75.81)
_write_msa(run_p, 0.6, -255.45)
_ck(absf(MsaMotion.move_speed(run_p) - 425.75) < 0.01, "run msa 速度 425.75%f" % MsaMotion.move_speed(run_p))
_ck(absf(MsaMotion.move_speed(walk_p) - 87.47) < 0.01, "walk msa 速度 87.47%f" % MsaMotion.move_speed(walk_p))
_ck(MsaMotion.move_speed("user://__no_such.msa") == 0.0, "缺文件 -> 0")
var mv: Node3D = MobView.new()
mv._dir = "res://__no_such_mob_dir__"
mv._motions = {"WAIT": "wait.msa", "WALK": walk_p, "RUN": run_p}
var sp: Vector2 = mv.get_move_motion_speeds()
_ck(absf(sp.x - 87.47) < 0.01 and absf(sp.y - 425.75) < 0.01, "get_move_motion_speeds%s" % sp)
var anim := FakeAnim.new()
mv.anim = anim
mv.set_move_speed(200)
mv.set_anim_state("wait")
_ck(anim.time_scale == 1.0, "wait 不受移动速度影响")
mv.set_anim_state("run")
_ck(anim.anim_path == run_p and anim.time_scale == 2.0, "run 按 movSpd/100 播放(%f" % anim.time_scale)
mv.set_move_speed(150)
_ck(anim.time_scale == 1.5, "跑动中改速立即生效(%f" % anim.time_scale)
mv.set_move_speed(1200)
_ck(anim.time_scale == 1.0, ">1100 保持原速(%f" % anim.time_scale)
mv.anim = null
anim.free()
mv.free()
DirAccess.remove_absolute(ProjectSettings.globalize_path(walk_p))
DirAccess.remove_absolute(ProjectSettings.globalize_path(run_p))
_move_speed_done = true
func _run() -> void:
_check_motion_resolution()
_check_move_speed()
_ck(_move_speed_done, "移动速度断言全部执行")
# 脚本运行时错误只会中止函数、不计失败;用哨兵保证整段断言都执行过。
_ck(_resolution_done, "动作解析断言全部执行")
if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2Proto"):
print(" (skip: 扩展未注册)")
return
+2 -1
View File
@@ -6,7 +6,8 @@ func _init() -> void:
func run() -> void:
for mode in ["0", "1"]:
OS.set_environment("MTGODOT_GPUSKIN", mode)
for race in [101, 110, 20001]:
# 101/110/20001 是原全怪物回归基线;2301-2315 是首测鬼木林树怪(MAP-01)。
for race in [101, 110, 20001, 2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]:
var view := MobView.new()
if not view.build(AssetRoot.path(), null, race):
failures += 1
+309 -95
View File
@@ -95,9 +95,21 @@ const MOTION_TYPE_NORMAL := 1
const MOTION_TYPE_COMBO := 2
const MOTION_TYPE_SKILL := 3
const HIT_ATTACK_RADIUS_CM := 20.0 # c_fAttackRadiusActorInstanceCollisionDetection.cpp:355
const HIT_VICTIM_RADIUS_CM := 45.0 # 受击方防御圆柱近似半径(真实取自 defending sphere 数据)
const HIT_FRONT_COS := 0.5 # 正面 ±60° 弧内才判定命中(近似动态圆柱扫掠)
const SYNC_POSITION_LIMIT := 16 # FlushVictimList 的 SYNC_POSITION_COUNT_LIMIT
# NRaceData::EHitTypeGameType.h
const HIT_TYPE_NONE := 0
const HIT_TYPE_GREAT := 1
const HIT_TYPE_GOOD := 2
const HIT_DISTANCE_CM := 300.0 # __NormalAttackProcessfDistance >= 300² -> FALSE
const HIT_DISTANCE_HUGE_CM := 500.0 # IS_HUGE_RACE(victim)>= 500² -> FALSE
const COMBO_HIT_LIMIT := 16 # MOTION_TYPE_COMBO / NORMAL 每个命中窗最多 16 个目标
const PUSH_OWNER_TIME := 3.0 # __CanPushDestActor__GetOwnerTime() > 3.0f -> 不推
const COMBO_SKILL_ID := 122 # SetComboSkillFlag 的 c_iSkillIndex_Combo
# m_dwBattleHitEffectIDplayersettingmodule.py RegisterCacheEffect(EFFECT_HIT, ...)
const EFFECT_HIT := "d:/ymir work/effect/hit/blow_1/blow_1_low.mse"
# 占位胶囊(没有 .msm 防御球)退化用的一个防御球(seam)
const HIT_FALLBACK_SPHERE := {"radius": 45.0, "pos": Vector3(0, 0, 100), "bone": ""}
const HitCollision = preload("res://hit_collision.gd")
var client: Node # M2Client
var pc: Node # PlayerController
@@ -108,10 +120,20 @@ var hud: Node # 可空
# `DEFAULT_ATTACK_PERIOD` 只在拿不到动作数据(如 headless 无 player_view)时兜底。
const DEFAULT_ATTACK_PERIOD := 0.6
var attack_period := DEFAULT_ATTACK_PERIOD # 上一次解析出的普攻节奏(秒),供 HUD / 测试读取
var _atk_speed_factor := 1.0 # bAttackSpeed / 1000.25..3),只缩放播放速率
var _atk_speed_factor := 1.0 # m_fAtkSpd = bAttackSpeed / 100>1100 -> 0),动作 fSpeedRatio
var camera: Node # GameCamera(可空,用于受击抖屏)
var player_view: Node # PlayerView(可空,set_anim_state("damage")
# PlayerView(可空)。motion_bound(state) = __SetMotion 尾(清命中表 / 连击段号)。
var player_view: Node:
set(v):
if is_instance_valid(player_view) and player_view.has_signal("motion_bound") \
and player_view.motion_bound.is_connected(_on_motion_bound):
player_view.motion_bound.disconnect(_on_motion_bound)
player_view = v
if v and v.has_signal("motion_bound") and not v.motion_bound.is_connected(_on_motion_bound):
v.motion_bound.connect(_on_motion_bound)
var fx # EffectRegistry(可空):命中特效 EFFECT_HIT
var fx_parent: Node3D # 命中特效挂载点(game_scene._mount
var proto: Node # Metin2Proto(可空,按 race 分类 NPC/怪)
const DAMAGE_DODGE := 1 << 2
@@ -188,18 +210,27 @@ var _combo_type := 0 # m_wcurComboTypeSetComboType;连击技能
var _combo_index := 0 # m_dwcurComboIndex0 = 不在连击中)
var _is_pre_input := false # m_isPreInput(已过 InputStartTime、待 NextComboTime 触发)
var _is_next_pre_input := false # m_isNextPreInput(弓箭:输入超限后排队下一击)
var _combo_started_t := 0.0 # 当前连击段动作起播时刻(GetAttackingElapsedTime 基准)
var _combo_tables := {} # { class:int -> { key:int -> PackedInt32Array } }
# CGraphicThingInstance 本地时间:UpdateTime 每帧 += elapsedInsertDelay 冻结(命中硬直)。
var _local_time := 0.0 # GetLocalTime()
var _delay := 0.0 # m_fDelay
var _motion_start_t := 0.0 # m_kCurMotNode.fStartTime(本地时间;GetAttackingElapsedTime 基准)
# §3.5 修改 4 —— 当前挥击的命中窗状态(m_pkCurRaceMotionData->GetMotionAttackDataPointer
# + m_HitDataMap + m_kVctkVictim 的等价)
var _swing_start_t := 0.0 # 本次挥击动作起播时刻(命中窗时间基准,独立于连击输入计时)
var _swing_skill := 0 # 本次挥击的 uSkill(普攻 0;技能施法由 §3.8 设置)
var _hit_windows := [] # get_motion_data().hit_windowsTHitDataContainer
var _hit_motion_type := 0 # get_motion_data().motion_typeNRaceData::EMotionType
var _hit_limit_count := 0 # get_motion_data().hit_limit_countSKILL 类的每窗命中上限)
var _hit_invisible_time := 0.0 # get_motion_data().invisible_time同目标再命中的冷却
var _hit_dedup := {} # { window_idx:int -> { victim_vid:int -> cooldown_until:float } }
var _hit_invisible_time := 0.0 # get_motion_data().invisible_timefInvisibleTime
var _hit_type := HIT_TYPE_NONE # get_motion_data().hitting_typeiHittingType
var _hit_stiffen := 0.0 # get_motion_data().stiffen_timefStiffenTime
var _hit_external_force := 0.0 # get_motion_data().external_forcefExternalForce
var _hit_dedup := {} # m_HitDataMap{ window_idx:int -> { victim_vid:int -> 本地时间 + fInvisibleTime } }
var _victim_flush := [] # 帧末 CG_SYNC_POSITION 的被击退者列表 [{vid,x,y}]
var _victim_invisible_until := {} # 受击方 m_fInvisibleTime{ vid -> 秒(_now()}
var _def_last := {} # 防御球 v3LastPosition{ vid -> {frame, pos: [Vector3 cm]} }
var _atk_frame := 0 # _attack_process 帧号(判断 _def_last 是否是上一帧)
var _owner_seen := {} # m_fOwnerBaseTime 的客户端观测:{ vid -> [owner_vid, 秒] }
var _observer_mode := false
var _attack_key_down := false
var _fishing_active := false # IsFishing(): 当前处于 WAIT/REACT 的本地主角
@@ -242,12 +273,14 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null) ->
client.points_changed.connect(_on_points)
client.vitals_changed.connect(_on_vitals)
client.target_info.connect(_on_target_info)
client.entity_despawned.connect(func(v): if v == _target_vid: _clear_target())
client.entity_despawned.connect(_on_entity_gone)
client.entity_dead.connect(func(v): if v == _target_vid: _clear_target())
if client.has_signal("damage"):
client.damage.connect(_on_damage)
if client.has_signal("fishing_event"):
client.fishing_event.connect(_on_fishing_event)
if client.has_signal("combo_changed"):
client.combo_changed.connect(_on_combo_changed)
if client.has_signal("observer_mode_changed"):
client.observer_mode_changed.connect(_on_observer_mode)
if client.has_method("is_observer_mode"):
@@ -357,6 +390,30 @@ func set_asset_root(root: String) -> void:
func set_combo_type(t: int) -> void:
_combo_type = clampi(t, 0, 2)
# CPythonPlayer::SetComboSkillFlagPythonPlayerSkill.cpp:916):GC 连击开关到达时查连击技能
# 122 的槽位 / 等级,缺槽或等级 <= 0 直接 return(不改 combo type),否则
# SetComboType(bFlag ? MIN(iLevel, 2) : 0)。
func _on_combo_changed(enabled: bool) -> void:
if client == null or not client.has_method("get_skills"):
return
var level := -1
for skill in client.get_skills():
if int(skill.get("id", 0)) == COMBO_SKILL_ID:
level = int(skill.get("level", 0))
break
if level <= 0:
return
_combo_type = mini(level, 2) if enabled else 0
# CActorInstance::__SetMotion 尾(ActorInstanceMotion.cpp):绑定带 MotionAttackData 的动作 ->
# m_HitDataMap.clear();连击中换到没有 ComboInputData 的动作 -> m_dwcurComboIndex = 0。
func _on_motion_bound(_state: String) -> void:
var md := _motion_data()
if bool(md.get("has_attacking_data", false)):
_hit_dedup.clear()
if _combo_index != 0 and not bool(md.get("has_combo_input", false)):
_combo_index = 0
func set_combo_motion_mode(mode: int) -> void:
if mode != combo_motion_mode:
combo_motion_mode = mode
@@ -627,6 +684,8 @@ func _on_main_set(vid: int) -> void:
net_world.set_local_vid(vid)
func _on_entity_info(vid: int, entity: Dictionary) -> void:
if entity.has("owner_vid"):
_note_owner(vid, int(entity.get("owner_vid", 0)))
if vid != _main_vid:
return
if pc and pc.has_method("set_server_speed"):
@@ -641,8 +700,9 @@ func _on_entity_info(vid: int, entity: Dictionary) -> void:
func _apply_attack_speed(speed: int) -> void:
# `CInstanceBase::SetAttackSpeed`bAttackSpeed / 100 只缩放动作播放速率,
# 不是一个独立的固定间隔。节奏本身由 `.msa` combo 时间给出(见 _current_attack_period)。
# 出货代码:uAtkSpd > 1100 -> 0,否则 uAtkSpd / 100,没有上下限。speed 0 = 数据未到,保留旧值。
if speed > 0:
_atk_speed_factor = clampf(float(speed) / 100.0, 0.25, 3.0)
_atk_speed_factor = 0.0 if speed > 1100 else float(speed) / 100.0
# 当前攻击 `.msa` 的普攻节奏(秒),已按攻速缩放。拿不到动作数据时用兜底常量。
func _current_attack_period() -> float:
@@ -650,8 +710,25 @@ func _current_attack_period() -> float:
var nc := float(_motion_data().get("next_combo", 0.0))
if nc > 0.0:
base = nc
if _atk_speed_factor <= 0.0:
return base # fSpeedRatio 0 = 动作冻结;节奏只作 HUD / 冷却兜底,不除零
return base / _atk_speed_factor
# CGraphicThingInstance::UpdateTimem_fDelay 先吃掉本帧 elapsed,余下才推进本地时间。
func _advance_local_time(dt: float) -> void:
var elapsed := dt
if _delay > elapsed:
_delay -= elapsed
elapsed = 0.0
else:
elapsed -= _delay
_delay = 0.0
_local_time += elapsed
# CActorInstance::GetAttackingElapsedTime(GetLocalTime() - fStartTime) * fSpeedRatio
func _attacking_elapsed() -> float:
return (_local_time - _motion_start_t) * _atk_speed_factor
# CInstanceBase::__GetBowRangeInstanceBase.cpp:694)——基础 + 主角 POINT_BOW_DISTANCE 加成。
func _bow_range_cm() -> float:
return BOW_RANGE_BASE_CM + _bow_distance_bonus
@@ -894,9 +971,29 @@ func skill_context() -> Dictionary:
"arrow_count": _arrow_count,
"cur_hp": cur_hp,
"cur_sp": cur_sp,
"bow_distance": int(_bow_distance_bonus),
"target_distance": Callable(self, "skill_target_distance_cm"),
}
# rkInstMain.GetDistance(&rkInstTarget)(含 IS_HUGE_RACE 200)——施法射程判定与
# MODE_USE_SKILL 预约趋近共用同一量度,保证预约触发时 __UseSkill 必然在射程内。
func skill_target_distance_cm(vid: int) -> float:
if pc == null or pc.player == null or client == null:
return -1.0
var tnode: Node3D = net_world.node_for(vid) if net_world else null
if tnode == null:
return -1.0
var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM
if _is_huge_race(int(client.get_entity(vid).get("race", 0))):
dist_cm -= HUGE_RACE_DIST_FIX_CM
return dist_cm
# __IsReservedUseSkill(dwSkillSlotIndex)
func is_use_skill_reserved(slot: int) -> bool:
return _reserved_mode == ReservedMode.USE_SKILL and _skill_slot_reserved == slot
func _process(dt: float) -> void:
_advance_local_time(dt)
# 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。
if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \
and not player_view.is_in_hit_reaction():
@@ -983,7 +1080,7 @@ func _emit_cannot(code: String) -> void:
# CInstanceBase::IsAttackableInstanceInstanceBase.cpp:2147)—— 逐行实现在 entity_rules.gd。
# 这里只负责把网络快照拼成 self_e / victim_e / ctx。参考端此函数**不查死亡**(死亡另在各
# 调用点判:__ReserveProcess_ClickActor 第 4 步、_hit_geometry 等)——本 POC 保留一个
# 调用点判:__ReserveProcess_ClickActor 第 4 步、_attack_process 等)——本 POC 保留一个
# dead 提前返回作为便利,多数调用点本来也会再查一次。
func _is_attackable(e: Dictionary) -> bool:
if bool(e.get("dead", false)):
@@ -1160,9 +1257,7 @@ func _reserve_process_use_skill() -> void:
if rv.is_empty() or tnode == null:
_clear_reserved()
return
var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM
if _is_huge_race(int(rv.get("race", 0))):
dist_cm -= HUGE_RACE_DIST_FIX_CM
var dist_cm := skill_target_distance_cm(_vid_reserved)
if dist_cm < _skill_range_reserved:
if _target_vid != _vid_reserved:
_target_vid = _vid_reserved
@@ -1408,7 +1503,7 @@ func _do_attack_swing(tnode: Node3D, _rv: Dictionary) -> void:
_run_next_combo(vec)
return
var t := _now() - _combo_started_t
var t := _attacking_elapsed()
var md := _motion_data()
if bool(md.get("has_combo_input", false)):
var t_start := float(md.get("pre_input_time", 0.0)) # GetComboInputStartTime
@@ -1434,7 +1529,6 @@ func _run_next_combo(vec: PackedInt32Array) -> void:
if arr_idx < 0 or arr_idx >= vec.size():
_combo_index -= 1 # 段号越界:参考端 TraceError + return,不推进
return
_combo_started_t = _now()
_emit_swing(int(vec[arr_idx]), _combo_index > 1)
if _combo_index == vec.size():
_on_end_combo()
@@ -1455,7 +1549,7 @@ func _clear_combo() -> void:
# CActorInstance::ComboProcessActorInstanceBattle.cpp:213)——每帧推进挂起输入 / 收尾。
func _combo_process() -> void:
if _combo_index != 0:
var t := _now() - _combo_started_t
var t := _attacking_elapsed()
var md := _motion_data()
var has_ci := bool(md.get("has_combo_input", false))
var full := float(md.get("duration", attack_period))
@@ -1469,9 +1563,8 @@ func _combo_process() -> void:
if not vec.is_empty():
_run_next_combo(vec)
return
# 动作已回到 Wait(超过整段时长、无挂起输入)→ __ClearCombo
if not _is_pre_input and t > full:
_clear_combo()
# 参考端这里没有「超过整段时长就清连击」:段号在动作回 Wait 时由 __SetMotion 尾
# _on_motion_bound)清零。
else:
_is_pre_input = false
if _is_next_pre_input and not _using_skill:
@@ -1501,13 +1594,17 @@ func _emit_swing(motion_index: int, is_combo: bool) -> void:
# OnAttack → SendCharacterStatePacket(FUNC_COMBO, wMotionIndex):这一包带连击段号
# PythonPlayerEventHandler.cpp:102),与 CG_ATTACK 是两个不同时刻的两个包。
_send_state(FUNC_COMBO, motion_index, pc.player.position)
# __SetMotion(SSetMotionData{ dwMotKey(mode, index), fSpeedRatio = m_fAtkSpd }):先绑定该段
# 动作,命中窗 / 连击时间才读得到这一段的 .msa。
if player_view and player_view.has_method("play_attack_motion"):
player_view.play_attack_motion(combo_motion_mode, motion_index, _atk_speed_factor)
elif player_view and player_view.has_method("set_anim_state"):
player_view.set_anim_state("combo" if is_combo else "attack")
_begin_hit_windows()
if _hit_windows.is_empty():
# CG_ATTACK 的 bType 是技能号,普攻恒 0,不是连击段号
# PythonPlayerEventHandler.cpp:135 / PhaseGame.cpp:2530)。
client.attack(_swing_skill, _vid_reserved)
if player_view and player_view.has_method("set_anim_state"):
player_view.set_anim_state("combo" if is_combo else "attack")
# 节奏来自动作数据(.msa ComboInputData.DirectInputTime / next_combo),
# 除以攻速系数;缺组时退化为 DEFAULT_ATTACK_PERIOD。§3.3
attack_period = _current_attack_period()
@@ -1528,27 +1625,33 @@ func _send_fly_target() -> void:
var sp := _server_xy(tnode.global_position)
client.add_fly_targeting(vid, int(sp.x), int(sp.y))
# 挥击起播:从当前 `.msa` 取 THitDataContainer + iMotionType + iHitLimitCount + fInvisibleTime
# 清空上一击的 m_HitDataMap(对齐 __ProcessNormalAttack 里对 m_HitDataMap 的重建)。
# 挥击起播:从刚绑定的 `.msa` 取 TMotionAttackDataTHitDataContainer + iMotionType +
# iHitLimitCount + fInvisibleTime + iHittingType + fStiffenTime + fExternalForce),
# m_kCurMotNode.fStartTime = 本地时间,清空上一击的 m_HitDataMap。
func _begin_hit_windows() -> void:
var md := _motion_data()
var w: Variant = md.get("hit_windows", [])
_hit_windows = w if w is Array else []
_hit_motion_type = int(md.get("motion_type", 0))
_hit_motion_type = int(md.get("motion_type", MOTION_TYPE_NONE))
_hit_limit_count = int(md.get("hit_limit_count", 0))
_hit_invisible_time = float(md.get("invisible_time", 0.0))
_hit_type = int(md.get("hitting_type", HIT_TYPE_NONE))
_hit_stiffen = float(md.get("stiffen_time", 0.0))
_hit_external_force = float(md.get("external_force", 0.0))
_hit_dedup.clear()
_swing_start_t = _now()
_motion_start_t = _local_time
# CInstanceBase::AttackProcessInstanceBaseBattle.cpp:413+ CActorInstance::__NormalAttackProcess
# ActorInstanceCollisionDetection.cpp:333——挥击动作播放中,每帧按 hit_window [start,end]
# 对所有可攻击实体做扫掠球几何判定,命中即 OnHitSendAttackPacket)。
# CInstanceBase::AttackProcessInstanceBaseBattle.cpp:413-> CActorInstance::AttackingProcess ->
# __NormalAttackProcessActorInstanceCollisionDetection.cpp:333挥击动作播放中,每帧用 .msa
# 刀尖扫掠球(半径 20)对每个可攻击实体的 .msm 防御球做 Z 圆柱检测,命中即
# __ProcessDataAttackSuccess(硬直 / 击退 / 特效 / 受击反应 / OnHit)。
func _attack_process(dt: float) -> void:
if _hit_windows.is_empty() or client == null or not client.is_in_game():
return
if pc == null or pc.player == null or net_world == null:
return
var t := _now() - _swing_start_t
_atk_frame += 1
var t := _attacking_elapsed()
# 动作已越过所有命中窗 -> 收起(等同 !CanCheckAttacking
var latest_end := 0.0
for w in _hit_windows:
@@ -1560,8 +1663,8 @@ func _attack_process(dt: float) -> void:
# CheckAttacking:自己在安全区直接不判定
if bool(_main_entity().get("in_safe", false)):
return
var a_pos := _actor_cm(pc.player.global_position)
var yaw := _player_yaw()
var origin: Vector3 = pc.player.global_position
for e in client.get_entities():
var vid := int(e.get("vid", 0))
if vid == 0 or vid == _main_vid:
@@ -1571,76 +1674,187 @@ func _attack_process(dt: float) -> void:
var vnode: Node3D = net_world.node_for(vid)
if vnode == null:
continue
for wi in _hit_windows.size():
var w: Dictionary = _hit_windows[wi]
var ws := float(w.get("start_time", 0.0))
var we := float(w.get("end_time", 0.0))
# 扫掠:动作时间窗 [t-dt, t] 与命中窗 [ws, we] 相交(对齐 lower_bound(motiontime-elapsed)
if t < ws or (t - dt) > we:
continue
if _hit_deduped(wi, vid):
continue
if not _hit_geometry(w, origin, yaw, vnode.global_position, t, dt):
continue
if not _register_hit(wi, vid):
break # 该窗命中数超上限 -> 本帧不再处理这个实体
# OnHitSetTarget(FALSE) + SendAttackPacket(uSkill, victimVID)
if _target_vid != vid:
_target_vid = vid
client.attack(_swing_skill, vid)
# IsPushing 的目标:帧末 CG_SYNC_POSITION 上报其被击退后的位置
if bool(e.get("knock_down", false)) or bool(e.get("stunned", false)):
var sp := _server_xy(vnode.global_position)
_victim_flush.append({"vid": vid, "x": int(sp.x), "y": int(sp.y)})
break # 一个实体一帧命中一次(对齐 __NormalAttackProcess 命中即 return TRUE
# AttackingProcessrVictim.__isInvisible() -> 跳过
if _now() < float(_victim_invisible_until.get(vid, 0.0)):
continue
_normal_attack_process(e, vid, vnode, a_pos, yaw, t, dt)
# m_HitDataMap 查重:COMBO 动作同窗同目标只一次;其余按 fInvisibleTime 冷却。
func _hit_deduped(wi: int, vid: int) -> bool:
var m: Dictionary = _hit_dedup.get(wi, {})
if not m.has(vid):
return false
if _hit_motion_type == MOTION_TYPE_COMBO:
return true
return float(m[vid]) > _now()
# Godot 世界 m -> 角色世界 cmX, Y, 高度)
static func _actor_cm(g: Vector3) -> Vector3:
return Vector3(g.x * CM, -g.z * CM, g.y * CM)
# 记一次命中并做每窗命中上限校验:NORMAL/COMBO 上限 16SKILL 用 iHitLimitCount。
# 返回 false = 超上限,本帧跳过(对齐 iCurrentHitCount > … return FALSE)。
func _register_hit(wi: int, vid: int) -> bool:
var m: Dictionary = _hit_dedup.get(wi, {})
var first := not m.has(vid)
m[vid] = _now() + _hit_invisible_time
_hit_dedup[wi] = m
if first:
return true
var cap := 16
if _hit_motion_type != MOTION_TYPE_COMBO and _hit_motion_type != MOTION_TYPE_NORMAL:
cap = _hit_limit_count
return m.size() <= cap
static func _godot_m(a: Vector3) -> Vector3:
return Vector3(a.x / CM, a.z / CM, -a.y / CM)
# 扫掠球几何近似:reach = max(WeaponLength, 窗内采样最大水平偏移),命中条件 = 目标落在
# 正面 reach 弧内。真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending sphere 数据(同 §3.1 留桩)。
func _hit_geometry(w: Dictionary, origin: Vector3, yaw: float, victim_pos: Vector3, t: float, dt: float) -> bool:
var reach_cm := float(w.get("weapon_length", 0.0))
var samples: Variant = w.get("samples", [])
if samples is Array:
for s in samples:
var st := float(s.get("time", 0.0))
# 模型本地 cm 按 Godot yaw 转到角色世界朝向(= Metin2 的 Z 轴旋转)
static func _rot_cm(v: Vector3, yaw: float) -> Vector3:
var c := cos(yaw)
var s := sin(yaw)
return Vector3(v.x * c - v.y * s, v.x * s + v.y * c, v.z)
func _normal_attack_process(e: Dictionary, vid: int, vnode: Node3D, a_pos: Vector3, yaw: float,
t: float, dt: float) -> void:
var v_pos := _actor_cm(vnode.global_position)
var huge := HitCollision.is_huge_race(int(e.get("race", 0)))
# 出货代码 v3Distance = (dX, dZ, dZ):平面 Y 不参与、高度差算两次(1:1 保留)
var dx := v_pos.x - a_pos.x
var dz := v_pos.z - a_pos.z
var dist2 := dx * dx + 2.0 * dz * dz
var lim := HIT_DISTANCE_HUGE_CM if huge else HIT_DISTANCE_CM
if dist2 >= lim * lim:
return
var spheres := _defending_spheres(vid, vnode, v_pos)
for wi in _hit_windows.size():
var w: Dictionary = _hit_windows[wi]
if t < float(w.get("start_time", 0.0)) or (t - dt) > float(w.get("end_time", 0.0)):
continue
# m_HitDataMap:同窗已命中过 -> COMBO 永不再判;其余要等 fInvisibleTime 过去
var m: Dictionary = _hit_dedup.get(wi, {})
if m.has(vid) and (_hit_motion_type == MOTION_TYPE_COMBO or float(m[vid]) > _local_time):
continue
var samples: Variant = w.get("samples", [])
if not (samples is Array):
continue
for smp in samples:
var st := float(smp.get("time", 0.0))
if st < t - dt or st > t:
continue
var p: Vector3 = s.get("pos", Vector3.ZERO)
reach_cm = maxf(reach_cm, Vector2(p.x, p.y).length())
if reach_cm <= 0.0:
reach_cm = CLICK_DIST_DEFAULT_CM
var max_dist := (reach_cm + HIT_ATTACK_RADIUS_CM + HIT_VICTIM_RADIUS_CM) / CM
var to: Vector3 = victim_pos - origin
to.y = 0.0
var d := to.length()
if d > max_dist:
return false
if d < 0.001:
var p: Vector3 = smp.get("pos", Vector3.ZERO)
var lp: Vector3 = smp.get("last_pos", p)
var d_pos := _rot_cm(p, yaw) + a_pos
var d_last := _rot_cm(lp, yaw) + a_pos
for sp in spheres:
if not HitCollision.detect_z_cylinder(d_last, d_pos, HIT_ATTACK_RADIUS_CM,
sp["last"], sp["pos"], float(sp["radius"])):
continue
if not _register_hit(wi, vid):
return
var hit_pos: Vector3 = (a_pos + (sp["pos"] as Vector3)) * 0.5 if huge else (a_pos + v_pos) * 0.5
_process_attack_success(e, vid, vnode, hit_pos, huge)
return
# 受击方 m_DefendingPointInstanceList:模型本地球心按受击方朝向旋转 + 受击方位置;
# v3LastPosition = 上一帧(没有上一帧就取本帧)。没有 .msm 的占位节点退化为一个球(seam)。
func _defending_spheres(vid: int, vnode: Node3D, v_pos: Vector3) -> Array:
var raw: Array = []
if vnode.has_method("get_defending_spheres"):
raw = vnode.get_defending_spheres()
if raw.is_empty():
raw = [HIT_FALLBACK_SPHERE]
var vyaw := vnode.rotation.y
var cur: Array = []
for sp in raw:
cur.append(_rot_cm(sp.get("pos", Vector3.ZERO), vyaw) + v_pos)
var prev: Dictionary = _def_last.get(vid, {})
var has_prev := int(prev.get("frame", -1)) == _atk_frame - 1 and (prev.get("pos", []) as Array).size() == cur.size()
var out: Array = []
for i in raw.size():
out.append({"radius": float(raw[i].get("radius", 0.0)), "pos": cur[i],
"last": prev["pos"][i] if has_prev else cur[i]})
_def_last[vid] = {"frame": _atk_frame, "pos": cur}
return out
# m_HitDataMap 登记(__NormalAttackProcess 命中分支):窗第一次命中 -> 新建 {vid: 本地时间 +
# fInvisibleTime} 直接处理;否则 map::insert(已在就不覆盖),NORMAL/COMBO 超 16、SKILL 超
# iHitLimitCount -> FALSE。
func _register_hit(wi: int, vid: int) -> bool:
if not _hit_dedup.has(wi):
_hit_dedup[wi] = {vid: _local_time + _hit_invisible_time}
return true
var fwd := Vector3(sin(yaw), 0.0, cos(yaw))
return fwd.dot(to / d) >= HIT_FRONT_COS
var m: Dictionary = _hit_dedup[wi]
if not m.has(vid):
m[vid] = _local_time + _hit_invisible_time
var cap := _hit_limit_count
if _hit_motion_type == MOTION_TYPE_COMBO or _hit_motion_type == MOTION_TYPE_NORMAL:
cap = COMBO_HIT_LIMIT
return m.size() <= cap
# CActorInstance::__ProcessDataAttackSuccessActorInstanceBattle.cpp
func _process_attack_success(e: Dictionary, vid: int, vnode: Node3D, hit_pos: Vector3, huge: bool) -> void:
if _hit_type == HIT_TYPE_NONE:
return
# InsertDelay(fStiffenTime):攻击者本地时间冻结(命中硬直)
_delay = _hit_stiffen
if player_view and player_view.has_method("insert_delay"):
player_view.insert_delay(_hit_stiffen)
var a_g: Vector3 = pc.player.global_position
var v_g := vnode.global_position
var to := v_g - a_g
# __PushCircle + IncreaseExternalForce
if _can_push(e, vid, huge) and _hit_external_force > 0.0 and net_world.has_method("push_victim"):
net_world.push_victim(vid, Vector2(to.x, -to.z).normalized(), _hit_external_force)
# 受击方 m_fInvisibleTime = 现在 + fInvisibleTime;受击方 InsertDelay
_victim_invisible_until[vid] = _now() + _hit_invisible_time
if vnode.has_method("insert_delay"):
vnode.insert_delay(_hit_stiffen)
var kind := _entity_kind(e)
var building := kind == EntityRules.KIND_BUILDING
var door := kind == EntityRules.KIND_WOODEN_DOOR
# 命中特效 m_dwBattleHitEffectID:建筑 / 门在攻击者身前 30 cm、不转;其余在受击方(巨型:命中点)
if fx and fx_parent and fx.has_method("spawn_at"):
var at := v_g
var rot := atan2(-to.x, -to.z)
if building or door:
var flat := Vector3(to.x, 0.0, to.z)
at = a_g + (flat.normalized() if flat.length() > 0.0 else Vector3.ZERO) * 0.3
rot = 0.0
elif huge:
at = _godot_m(hit_pos)
var eff: Node3D = fx.spawn_at(EFFECT_HIT, fx_parent, at, true)
if eff:
eff.rotation.y = rot
# __HitStone / __HitGood / __HitGreate
if not building:
var stunned := bool(e.get("stunned", false))
var scalar := cos(_player_yaw() - vnode.rotation.y)
if kind == EntityRules.KIND_STONE or door:
if vnode.has_method("hit_stone"):
vnode.hit_stone(stunned)
elif _hit_type == HIT_TYPE_GOOD:
if vnode.has_method("hit_good"):
vnode.hit_good(scalar, stunned)
elif _hit_type == HIT_TYPE_GREAT:
if vnode.has_method("hit_greate"):
vnode.hit_greate(scalar, stunned)
_on_hit(vid, huge)
# CActorInstance::__CanPushDestActorActorInstanceBattle.cpp
func _can_push(e: Dictionary, vid: int, huge: bool) -> bool:
var kind := _entity_kind(e)
if kind in [EntityRules.KIND_BUILDING, EntityRules.KIND_WOODEN_DOOR, EntityRules.KIND_STONE, EntityRules.KIND_NPC]:
return false
if huge:
return false
if bool(e.get("stunned", false)):
return true
var owner := int(e.get("owner_vid", 0))
_note_owner(vid, owner)
if owner != _main_vid:
return false
return _now() - float(_owner_seen[vid][1]) <= PUSH_OWNER_TIME
# m_dwOwnerVID / m_fOwnerBaseTime:服务端 owner 变化时记下客户端观测时刻(__GetOwnerTime 的近似)。
func _note_owner(vid: int, owner: int) -> void:
var seen: Array = _owner_seen.get(vid, [])
if seen.is_empty() or int(seen[0]) != owner:
_owner_seen[vid] = [owner, _now()]
# CPythonPlayerEventHandler::OnHitSetTarget(victim, FALSE) + SendAttackPacket(uSkill, victim)
# 受击方正被击退且不是巨型 -> 记进帧末 CG_SYNC_POSITION 列表(GetBlendingPosition)。
func _on_hit(vid: int, huge: bool) -> void:
_target_vid = vid
client.attack(_swing_skill, vid)
if huge or not net_world.has_method("is_pushing") or not net_world.is_pushing(vid):
return
var sp := _server_xy(net_world.blending_position(vid))
_victim_flush.append({"vid": vid, "x": int(sp.x), "y": int(sp.y)})
func _on_entity_gone(vid: int) -> void:
_owner_seen.erase(vid)
_victim_invisible_until.erase(vid)
_def_last.erase(vid)
if vid == _target_vid:
_clear_target()
# CPythonPlayerEventHandler::FlushVictimListPythonPlayerEventHandler.cpp:194)——帧末把
# 被击退目标的位置一次性 CG_SYNC_POSITION 上行,上限 SYNC_POSITION_COUNT_LIMIT=16。
+98 -10
View File
@@ -27,6 +27,7 @@ const PickShow = preload("res://pick_show.gd")
const NameTailLayout = preload("res://name_tail_layout.gd")
const TextMetrics = preload("res://text_metrics.gd")
const ScreenHpBar = preload("res://screen_hp_bar.gd")
const PhysicsPush = preload("res://physics_push.gd")
signal entity_added(node: Node3D, vid: int)
signal entity_removed(vid: int)
@@ -47,6 +48,12 @@ const FUNC_COMBO := 3
const FUNC_MOB_SKILL := 4
const FUNC_EMOTION := 5
const FUNC_SKILL := 0x80
# packet.h EWalkModesGC_WALK_MODE / GC_CHARACTER_ADD_INFO.bWalkMode
const WALKMODE_RUN := 0
const WALKMODE_WALK := 1
# CActorInstance c_fDefaultRotationSpeed / c_fDefaultHorseRotationSpeed(度/秒)
const ROTATION_SPEED := 1200.0
const ROTATION_SPEED_HORSE := 300.0
# EDamageFlag / IsShowDamage / ProcessDamage 分类见 damage_effect.gdDamageEffect.DAMAGE_*)。
@@ -93,6 +100,8 @@ var follow_lerp := 14.0
var snap_dist := 6.0
var _by_vid := {} # vid:int -> Node3D
# §3.7 受击击退:vid -> {obj: PhysicsPush(CPhysicsObject), off: Vector2 累计位移 cm, base: 推开时的 e.pos}
var _push := {}
var _main_vid := 0
var _local_vid := 0 # 由 net_play 设:这个 vid 由本地 player 代表,不生成节点
var _local_node: Node3D # 本地玩家的真模型(不挂在 _mount 下)
@@ -420,12 +429,26 @@ func _on_spawn(d: Dictionary) -> void:
_refresh_shop_sign(node, str(d.get("shop_sign", "")))
_refresh_pvp_tag(vid)
_apply_field_updates(node, d)
_push_motion_speeds(vid, node)
_apply_name_color(vid)
_apply_text_tail(vid)
entity_added.emit(node, vid)
if vid == _main_vid:
main_entity_ready.emit(node, vid)
# CActorInstance::Move -> SetLoopMotion(WALK/RUN, .., m_fMovSpd):远端位移按动作根运动
# 累计(.msa Accumulation / MotionDuration × movSpd/100)推进。模型建好后把走 / 跑
# 速度交给 EntityStore;占位胶囊 / 骑马(马的动作不在 general 目录)不推,EntityStore
# 退回按服务端 duration 线性插值。
func _push_motion_speeds(vid: int, n: Node3D) -> void:
if not n.has_method("get_move_motion_speeds") or not client.has_method("set_entity_motion_speed"):
return
if int(n.get_meta("mount_vnum", 0)) != 0:
return
var sp: Vector2 = n.call("get_move_motion_speeds")
if sp.x > 0.0 or sp.y > 0.0:
client.call("set_entity_motion_speed", vid, sp.x, sp.y)
# GC_CHAR_ADD_INFO 到了:刷新头顶名字 / HP 上限(节点已存在)。
func _on_info(vid: int, d: Dictionary) -> void:
var n: Node3D = _by_vid.get(vid, null)
@@ -836,6 +859,7 @@ func _on_despawn(vid: int) -> void:
_duel_opponents.erase(vid)
_fading.erase(vid)
_chat_tails.erase(vid)
_push.erase(vid)
_remove_hp_bar(vid)
var n: Node3D = _by_vid.get(vid, null)
if n:
@@ -1072,11 +1096,14 @@ func _on_dead(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return
# 倒地 + 变灰(服务端随后会发 GC_CHARACTER_DEL 再真正移除)
var t := create_tween()
t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3)
# CActorInstance::Die 只 InterceptOnceMotion(NAME_DEAD),不倾倒模型;
# 只有没有动作的占位胶囊才用倒地作为唯一的死亡表现。
if n.has_method("set_anim_state"):
n.set_meta("dead", true)
n.call("set_anim_state", "dead")
else:
var t := create_tween()
t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3)
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETINGtarget VID=0 时是坐标),所以这里也消费
@@ -1276,26 +1303,36 @@ func _process(dt: float) -> void:
# SNetworkActorData::UpdatePosition 式线性插值),这里只跟随 e.pos,不再
# 叠第二层 follow_lerp——否则会把 C++ 的到达时机拖慢、和动作事件脱节。
var want := _grounded(e.get("pos", n.position))
if _push.has(vid):
want = _pushed_position(vid, e, dt, want)
n.position = want
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
# CActorInstance::RotationProcess:匀角速度转向 c_fDefaultRotationSpeed 1200°/s
# 骑马 c_fDefaultHorseRotationSpeed 300°/s(而不是指数逼近)。
var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0)))
n.rotation.y = lerp_angle(n.rotation.y, yaw, clampf(10.0 * dt, 0.0, 1.0))
var turn_rate := ROTATION_SPEED_HORSE if int(n.get_meta("mount_vnum", 0)) != 0 else ROTATION_SPEED
n.rotation.y = rotate_toward(n.rotation.y, yaw, deg_to_rad(turn_rate) * dt)
# 动画状态
var f := int(e.get("func", FUNC_WAIT))
var moving := bool(e.get("moving", false))
var walk_mode := int(e.get("walk_mode", 1))
var walk_mode := int(e.get("walk_mode", WALKMODE_RUN))
var dead := bool(e.get("dead", false))
if f != int(n.get_meta("func", -1)) or moving != bool(n.get_meta("moving", false)) \
or walk_mode != int(n.get_meta("walk_mode", -1)):
or walk_mode != int(n.get_meta("walk_mode", -1)) or dead != bool(n.get_meta("dead", false)):
n.set_meta("func", f)
n.set_meta("moving", moving)
n.set_meta("walk_mode", walk_mode)
_apply_anim(n, f, moving, walk_mode)
n.set_meta("dead", dead)
_apply_anim(n, f, moving, walk_mode, dead)
_update_hp_bars()
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void:
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := WALKMODE_RUN, dead := false) -> void:
var state := "wait"
if f == FUNC_MOVE or (f == FUNC_WAIT and moving):
state = "walk" if walk_mode == 0 else "run"
if dead:
# CActorInstance::__SetMotionIsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。
state = "dead"
elif f == FUNC_MOVE or (f == FUNC_WAIT and moving):
state = "walk" if walk_mode == WALKMODE_WALK else "run"
elif f == FUNC_ATTACK or f == FUNC_COMBO:
state = "attack"
elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0:
@@ -1310,10 +1347,61 @@ func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void:
func _grounded(p: Variant) -> Vector3:
# p 是 M2Client 的网络帧 pos —— 先转到 Metin2World 本地帧,再贴地。
var v: Vector3 = MapCoord.to_world(p) if p is Vector3 else Vector3.ZERO
return _ground_world(v)
func _ground_world(v: Vector3) -> Vector3:
if world and world.has_method("sample_height"):
v.y = float(world.call("sample_height", v.x, v.z))
return v
# §3.7 __PushCircle + IncreaseExternalForceActorInstanceBattle.cpp / PhysicsObject.cpp):
# dir = normalize(受击方 - 攻击方)Metin2 actor 平面帧(cm)。seam:无 IPhysicsWorld 地形碰撞回调。
func push_victim(vid: int, dir: Vector2, force: float) -> void:
if client == null or force <= 0.0 or not _by_vid.has(vid):
return
var e: Dictionary = client.get_entity(vid)
if e.is_empty():
return
var pe: Dictionary = _push.get(vid, {})
if pe.is_empty():
pe = {"obj": PhysicsPush.new(), "off": Vector2.ZERO, "base": e.get("pos")}
_push[vid] = pe
pe.obj.set_direction(dir)
pe.obj.increase_external_force(force)
# CActorInstance::IsPushing -> m_PhysicsObject.isBlending()
func is_pushing(vid: int) -> bool:
return _push.has(vid) and bool(_push[vid].obj.is_blending())
# CActorInstance::GetBlendingPositionblending 中 = 当前位置 + LastPosition(击退终点),否则当前位置。
func blending_position(vid: int) -> Vector3:
var n: Node3D = _by_vid.get(vid, null)
if n == null:
return Vector3.ZERO
if not is_pushing(vid):
return n.global_position
var last: Vector2 = _push[vid].obj.get_last_position()
return n.global_position + Vector3(last.x / 100.0, 0.0, -last.y / 100.0)
# CActorInstance::PhysicsProcess:每帧 m_PhysicsObject.Update + AddMovement(GetX/YMovement)。
# 位移叠在服务器位置上;blend 结束后服务器位置一变,以服务器为准丢弃本地偏移(seam)。
func _pushed_position(vid: int, e: Dictionary, dt: float, want: Vector3) -> Vector3:
var pe: Dictionary = _push[vid]
var obj = pe.obj
var pos: Variant = e.get("pos")
if not (pos is Vector3):
return want
if pos != pe.base:
if not obj.is_blending():
_push.erase(vid)
return want
pe.base = pos
if obj.is_blending():
obj.update(dt)
pe.off += obj.get_movement()
var off: Vector2 = pe.off
return _ground_world(MapCoord.to_world(pos) + Vector3(off.x / 100.0, 0.0, -off.y / 100.0))
func _make_placeholder(d: Dictionary) -> Node3D:
var root := Node3D.new()
var mesh := MeshInstance3D.new()
+125
View File
@@ -0,0 +1,125 @@
# net_world_push_test —— CLIENT-GAP §3.7 受击击退的场景侧:
# __PushCircle + IncreaseExternalForce -> net_world.push_victim
# CActorInstance::PhysicsProcess(每帧 AddMovement(GetX/YMovement)-> 节点偏移
# GetBlendingPositionblending 中 = 当前位置 + LastPosition-> OnHit 的 CG_SYNC_POSITION 坐标
# godot --headless --path project --script net_world_push_test.gd
extends SceneTree
const NetWorld = preload("res://net_world.gd")
const PhysicsPush = preload("res://physics_push.gd")
class FakeClient extends Node:
signal entity_spawned(entity: Dictionary)
signal entity_despawned(vid: int)
signal entity_moved(vid: int)
signal entity_main_set(vid: int)
signal entity_info(vid: int, entity: Dictionary)
signal chat(type: int, vid: int, text: String)
signal vitals_changed(vid: int)
signal entity_dead(vid: int)
signal damage(vid: int, amount: int, flag: int)
var ents := {}
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func get_pvp_relations() -> Array: return []
func get_duel() -> Dictionary: return {}
func add(vid: int, cm: Vector2, extra := {}) -> Dictionary:
var d := {"vid": vid, "name": "e%d" % vid, "is_main": false,
"func": 0, "moving": false, "angle_deg": 0.0,
"hp": 100, "max_hp": 100, "dead": false, "race": 0,
"affect_flags": 0, "parts": [0, 0, 0, 0]}
d.merge(extra, true)
ents[vid] = d
place(vid, cm)
return d
func place(vid: int, cm: Vector2) -> void:
ents[vid]["pos"] = Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01)
ents[vid]["pos_cm"] = Vector3(cm.x, cm.y, 0.0)
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
var _done := false
func _init() -> void:
await _run()
_ck(_done, "test body ran to completion (a SCRIPT ERROR aborted _run)")
if _fail == 0:
print("PASS: net_world_push_test (push_victim / PhysicsProcess offset / GetBlendingPosition)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _off(v: Vector2) -> Vector3:
return Vector3(v.x / 100.0, 0.0, -v.y / 100.0)
func _run() -> void:
var mount := Node3D.new()
get_root().add_child(mount)
var fc := FakeClient.new()
get_root().add_child(fc)
var nw: Node = NetWorld.new()
get_root().add_child(nw)
nw.setup(fc, mount)
fc.add(1, Vector2.ZERO, {"is_main": true})
fc.entity_main_set.emit(1)
fc.entity_spawned.emit(fc.add(2, Vector2(300, 0)))
await process_frame
nw._process(0.02)
var n: Node3D = nw.node_for(2)
_ck(n != null, "victim node spawned")
if n == null:
return
_ck(not nw.is_pushing(2), "no push -> not pushing")
_ck(nw.blending_position(2).is_equal_approx(n.global_position), "not blending -> GetBlendingPosition = current position")
# 攻击者在西边:victim - attacker = actor-world (+1, 0)
nw.push_victim(2, Vector2(1, 0), 3.0)
var ref := PhysicsPush.new()
ref.set_direction(Vector2(1, 0))
ref.increase_external_force(3.0)
var last := ref.get_last_position()
_ck(last.x > 0.0, "reference push has a positive displacement")
_ck(nw.is_pushing(2), "push_victim -> is_pushing")
_ck(nw.blending_position(2).is_equal_approx(n.global_position + _off(last)),
"blending -> GetBlendingPosition = current + LastPosition, got %s" % nw.blending_position(2))
var acc := Vector2.ZERO
for i in 60:
nw._process(0.02)
ref.update(0.02)
acc += ref.get_movement()
var want: Vector3 = nw._grounded(fc.ents[2]["pos"]) + _off(acc)
_ck(acc.x > 0.0 and n.position.is_equal_approx(want),
"PhysicsProcess: node follows the accumulated ease-out movement, got %s want %s" % [n.position, want])
# blending 中服务器位置变化:偏移叠在新位置上(位移还在进行)
fc.place(2, Vector2(300, 50))
nw._process(0.02)
ref.update(0.02)
acc += ref.get_movement()
want = nw._grounded(fc.ents[2]["pos"]) + _off(acc)
_ck(n.position.is_equal_approx(want), "server move while blending -> offset kept on top, got %s want %s" % [n.position, want])
for i in 100:
nw._process(0.02)
ref.update(0.02)
acc += ref.get_movement()
_ck(not nw.is_pushing(2), "blend over -> not pushing")
want = nw._grounded(fc.ents[2]["pos"]) + _off(acc)
_ck(n.position.is_equal_approx(want), "blend over -> actor stays where the push left it, got %s want %s" % [n.position, want])
# blend 结束后服务器位置再变:以服务器位置为准(丢弃本地击退偏移)
fc.place(2, Vector2(320, 50))
nw._process(0.02)
_ck(n.position.is_equal_approx(nw._grounded(fc.ents[2]["pos"])), "server position after the blend supersedes the push offset")
nw.push_victim(2, Vector2(0, 1), 3.0)
fc.entity_despawned.emit(2)
_ck(not nw.is_pushing(2) and not nw._push.has(2), "despawn drops the push state")
_done = true
+74
View File
@@ -24,6 +24,9 @@ class FakeClient extends Node:
signal entity_dead(vid: int)
signal damage(vid: int, amount: int, flag: int)
var ents := {}
var motion_speeds := {} # vid -> Vector2(walk, run)
func set_entity_motion_speed(vid: int, walk: float, run: float) -> void:
motion_speeds[vid] = Vector2(walk, run)
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func get_pvp_relations() -> Array: return []
@@ -124,3 +127,74 @@ func _run() -> void:
await process_frame
_ck(nw.node_for(2) != null, "main back in range -> culled entity rebuilt after its fade")
_ck(int(added.get(2, 0)) == 2, "entity 2 rebuilt exactly once on re-entry")
await _dead_motion(fc, nw)
await _walk_mode_motion(fc, nw)
class AnimView extends Node3D:
var states: Array[String] = []
func set_anim_state(s: String) -> void:
states.append(s)
# MAP-02 / CActorInstance::Die: a model plays NAME_DEAD only (no scene tilt), and
# __SetMotion rejects every non-dead motion while the actor is dead — the
# func/moving edge that arrives with the death must not restart WAIT.
func _dead_motion(fc: FakeClient, nw: Node) -> void:
nw.set_model_factory(func(d: Dictionary) -> Node3D:
return AnimView.new() if int(d.get("race", 0)) == 2301 else null)
var ent := fc.add(6, NEAR_CM + Vector2(300, 0), {"race": 2301, "ch_type": 2, "moving": true})
fc.entity_spawned.emit(ent)
await process_frame
var view := nw.node_for(6) as AnimView
_ck(view != null and view.states.back() == "run", "moving tree monster -> run requested")
if view == null:
return
ent["moving"] = false
ent["dead"] = true
fc.entity_dead.emit(6)
await create_tween().tween_interval(0.4).finished
await process_frame
_ck(view.states.back() == "dead", "death while moving keeps DEAD (reference __SetMotion ignores WAIT when dead): %s" % [view.states])
_ck(is_zero_approx(view.rotation.x) and is_zero_approx(view.rotation.z), "model with motions is not tilted on death (rotation.x=%.2f)" % view.rotation.x)
var capsule := fc.add(7, NEAR_CM + Vector2(600, 0), {"ch_type": 2})
fc.entity_spawned.emit(capsule)
var placeholder: Node3D = nw.node_for(7)
capsule["dead"] = true
fc.entity_dead.emit(7)
await create_tween().tween_interval(0.4).finished
_ck(placeholder != null and placeholder.rotation.x < -1.0, "placeholder capsule (no motions) still falls over as its only death cue")
class MoveView extends AnimView:
func get_move_motion_speeds() -> Vector2:
return Vector2(87.5, 425.75)
# packet.h WALKMODE_RUN = 0 / WALKMODE_WALK = 1CActorInstance::Move 按 m_isWalking
# 选 WALK / RUN。模型建好后把走 / 跑根运动速度推给 EntityStore(骑马不推)。
func _walk_mode_motion(fc: FakeClient, nw: Node) -> void:
nw.set_model_factory(func(_d: Dictionary) -> Node3D: return MoveView.new())
var walker := fc.add(8, NEAR_CM + Vector2(900, 0), {"race": 101, "ch_type": 2, "func": 1, "walk_mode": 1})
fc.entity_spawned.emit(walker)
var runner := fc.add(9, NEAR_CM + Vector2(1200, 0), {"race": 101, "ch_type": 2, "func": 1, "walk_mode": 0})
fc.entity_spawned.emit(runner)
var rider := fc.add(10, NEAR_CM + Vector2(1500, 0), {"race": 101, "ch_type": 0, "mount_vnum": 20030})
fc.entity_spawned.emit(rider)
await process_frame
await process_frame # process_frame 在节点 _process 之前发
var wv := nw.node_for(8) as AnimView
var rv := nw.node_for(9) as AnimView
_ck(wv != null and wv.states.back() == "walk", "walk_mode 1 (WALKMODE_WALK) -> walk: %s" % [wv.states if wv else []])
_ck(rv != null and rv.states.back() == "run", "walk_mode 0 (WALKMODE_RUN) -> run: %s" % [rv.states if rv else []])
_ck(fc.motion_speeds.get(8, Vector2.ZERO) == Vector2(87.5, 425.75), "spawn pushes walk/run motion speeds: %s" % [fc.motion_speeds])
_ck(not fc.motion_speeds.has(10), "mounted actor keeps the duration lerp (no push)")
walker["walk_mode"] = 0
await process_frame
await process_frame
_ck(wv.states.back() == "run", "GC_WALK_MODE flip to run re-selects RUN: %s" % [wv.states])
# 匀角速度转向:一帧转角不超过 1200°/s × dt。
var before := wv.rotation.y
walker["angle_deg"] = 180.0
await process_frame
var t0 := Time.get_ticks_usec()
await process_frame
var dt := float(Time.get_ticks_usec() - t0) / 1e6
_ck(absf(angle_difference(before, wv.rotation.y)) <= deg_to_rad(1200.0) * (dt + 0.05), "turn limited to 1200deg/s")
+7
View File
@@ -289,8 +289,15 @@ func _run() -> void:
var hook_calls: Array = []
np.use_skill_hook = func(slot: int) -> bool: hook_calls.append(slot); return true
pc.player.position = Vector3.ZERO
_ck(is_equal_approx(np.skill_target_distance_cm(4100), 3000.0), "skill_target_distance_cm = node distance in cm")
_ck(np.skill_target_distance_cm(424242) < 0.0, "skill_target_distance_cm unknown vid -> -1")
var ctx_distance: Callable = np.skill_context().get("target_distance", Callable())
_ck(ctx_distance.is_valid() and is_equal_approx(float(ctx_distance.call(4100)), 3000.0),
"skill_context exposes the same distance to the PlayerSkill gate")
_ck(not np.is_use_skill_reserved(5), "no reservation yet")
np.reserve_use_skill(4100, 5, 800.0)
_ck(is_equal_approx(np._skill_range_reserved, 790.0), "reserve_use_skill trims range >100 by 10")
_ck(np.is_use_skill_reserved(5) and not np.is_use_skill_reserved(6), "__IsReservedUseSkill matches the reserved slot")
pc.walk_calls.clear()
fc.calls.set_target.clear()
await process_frame
+129
View File
@@ -0,0 +1,129 @@
# PhysicsPush —— CLIENT-GAP §3.7:受击击退,逐行移植 GameLib/PhysicsObject.cpp `CPhysicsObject`
# + GameLib/MapUtil.cpp `CEaseOutInterpolation`。单位 = Metin2 cm(平面 x / y)。
# __PushCircle -> set_direction(normalize(victim - attacker))
# IncreaseExternalForce(base, fExternalForce) -> increase_external_force(force[, collides])
# PhysicsProcessupdate(dt) 后 AddMovement(get_movement())
extends RefCounted
const FRAME_TIME := 0.02 # c_fFrameTime
const EPSILON := 0.001
const LOOP_VALUE := 100
const FLT_EPSILON := 1.192092896e-07
class EaseOut:
var remaining := 0.0
var value := 0.0
var speed := 0.0
var acceleration := 0.0
var start_value := 0.0
var last_value := 0.0
func initialize() -> void:
remaining = 0.0
value = 0.0
speed = 0.0
acceleration = 0.0
start_value = 0.0
last_value = 0.0
func setup(f_start: float, f_end: float, f_time: float) -> void:
if absf(f_time) < FLT_EPSILON:
f_time = 0.01
value = f_start
start_value = f_start
last_value = f_start
speed = (2.0 * (f_end - f_start)) / f_time
acceleration = 2.0 * (f_end - f_start) / (f_time * f_time) - 2.0 * speed / f_time
remaining = f_time
func interpolate(dt: float) -> void:
last_value = value
remaining -= dt
speed += acceleration * dt
value += speed * dt
if not is_playing():
value = 0.0
last_value = 0.0
func is_playing() -> bool:
return remaining > 0.0
func changing_value() -> float:
return value - last_value
var mass := 1.0
var friction := 0.3
var direction := Vector3.ZERO
var acceleration := Vector3.ZERO
var velocity := Vector3.ZERO
var last_position := Vector3.ZERO
var _x := EaseOut.new()
var _y := EaseOut.new()
func initialize() -> void:
mass = 1.0
friction = 0.3
direction = Vector3.ZERO
acceleration = Vector3.ZERO
velocity = Vector3.ZERO
last_position = Vector3.ZERO
_x.initialize()
_y.initialize()
func set_direction(dir: Vector2) -> void:
direction = Vector3(dir.x, dir.y, 0.0)
func update(dt: float) -> void:
if _x.is_playing():
_x.interpolate(dt)
if _y.is_playing():
_y.interpolate(dt)
func _accumulate(pos: Vector3) -> Vector3:
var force := 0.0
if absf(velocity.x) < EPSILON or absf(velocity.y) < EPSILON or absf(velocity.z) < EPSILON:
force -= mass * friction
acceleration = direction * (force / mass)
velocity += acceleration
if velocity.x * direction.x < EPSILON:
velocity.x = 0.0
direction.x = 0.0
if velocity.y * direction.y < EPSILON:
velocity.y = 0.0
direction.y = 0.0
if velocity.z * direction.z < EPSILON:
velocity.z = 0.0
direction.z = 0.0
return pos + velocity
# collides(movement: Vector2) -> bool 对应 IPhysicsWorld::isPhysicalCollision(base + movement)
# 空 Callable = 没有物理世界(参考端 pWorld == NULL 分支)。
func increase_external_force(force: float, collides := Callable()) -> void:
acceleration = direction * (force / mass)
velocity = acceleration
var movement := Vector3.ZERO
for i in LOOP_VALUE:
movement = _accumulate(movement)
if collides.is_valid() and bool(collides.call(Vector2(movement.x, movement.y))):
initialize()
return
if absf(velocity.x) < EPSILON and absf(velocity.y) < EPSILON and absf(velocity.z) < EPSILON:
break
set_last_position(Vector2(movement.x, movement.y), float(LOOP_VALUE) * FRAME_TIME)
func set_last_position(p: Vector2, blending_time: float) -> void:
last_position = Vector3(p.x, p.y, 0.0)
_x.setup(0.0, p.x, blending_time)
_y.setup(0.0, p.y, blending_time)
func get_last_position() -> Vector2:
return Vector2(last_position.x, last_position.y)
# GetXMovement / GetYMovement:本帧 ease-out 增量。
func get_movement() -> Vector2:
return Vector2(_x.changing_value(), _y.changing_value())
func is_blending() -> bool:
if velocity.length() != 0.0:
return true
return _x.is_playing() or _y.is_playing()
+72
View File
@@ -0,0 +1,72 @@
# physics_push_test —— §3.7:受击击退物理(GameLib/PhysicsObject.cpp CPhysicsObject +
# MapUtil.cpp CEaseOutInterpolationheadless 自检。
# godot --headless --path project --script physics_push_test.gd
extends SceneTree
const PhysicsPush = preload("res://physics_push.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: physics_push_test (external force / friction / ease-out blend)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
# 初始:不在 blending,每帧位移 0
var p := PhysicsPush.new()
_ck(not p.is_blending(), "fresh object -> not blending")
p.update(0.02)
_ck(p.get_movement() == Vector2.ZERO, "fresh object -> zero movement")
# IncreaseExternalForce(F=3, dir +X)vel 3 -> 每步摩擦 0.3,累计 2.7+2.4+…+0.3 = 13.5 cm
p.set_direction(Vector2(1, 0))
p.increase_external_force(3.0)
_ck(absf(p.get_last_position().x - 13.5) < 0.001 and absf(p.get_last_position().y) < 0.001,
"F=3 along +X -> last position (13.5, 0), got %s" % p.get_last_position())
_ck(p.is_blending(), "after push -> blending (ease-out playing)")
# 逐帧 AddMovement(GetXMovement, GetYMovement)2 秒 ease-out 累计 ≈ 13.5(离散 Euler 少 1/N
var sum := Vector2.ZERO
var frames := 0
while p.is_blending() and frames < 1000:
p.update(0.02)
sum += p.get_movement()
frames += 1
_ck(frames >= 99 and frames <= 101, "blend lasts LoopValue*c_fFrameTime = 2s, got %d frames" % frames)
_ck(absf(sum.x - 13.5) < 0.3 and absf(sum.y) < 0.001, "ease-out total movement ≈ 13.5, got %s" % sum)
p.update(0.02)
_ck(p.get_movement() == Vector2.ZERO, "blend done -> movement 0")
# 斜向:分量各自摩擦,13.5 * cos45
var q := PhysicsPush.new()
q.set_direction(Vector2(1, 1).normalized())
q.increase_external_force(3.0)
_ck(absf(q.get_last_position().x - 13.5 * 0.70710678) < 0.01 and absf(q.get_last_position().y - 13.5 * 0.70710678) < 0.01,
"diagonal push -> per-axis 9.546, got %s" % q.get_last_position())
# 分量 vel*dir < EPSILON(0.001) 当步清零(3*0.01*0.01 = 3e-4
var w := PhysicsPush.new()
w.set_direction(Vector2(0.99995, 0.01))
w.increase_external_force(3.0)
_ck(w.get_last_position().y == 0.0, "tiny axis component zeroed on first step, got %s" % w.get_last_position())
# 力为 0:没有位移,也不 blendingSetup(0,0,2) 仍在播放 -> isBlending 为真,与参考端一致)
var z := PhysicsPush.new()
z.set_direction(Vector2(1, 0))
z.increase_external_force(0.0)
_ck(z.get_last_position() == Vector2.ZERO, "zero force -> no movement")
# 撞墙(IPhysicsWorld::isPhysicalCollision 为真)-> Initialize(),不推
var c := PhysicsPush.new()
c.set_direction(Vector2(1, 0))
c.increase_external_force(3.0, func(_moved: Vector2) -> bool: return true)
_ck(c.get_last_position() == Vector2.ZERO and not c.is_blending(), "world collision -> Initialize, no push")
+355
View File
@@ -0,0 +1,355 @@
extends SceneTree
## CBT-01 离线事件与输入断言:真实 Quickbar + PlayerSkill + SkillTable 发出的请求,
## 经真实 PlayableProbe / PlayableFlow 归档。只有假客户端记录请求,不连服务器。
##
## godot --headless --path project --script playable_combat_test.gd
##
## 覆盖 §7.2:拒绝不发有效施法请求、正常请求不重复、无目标、超距、冷却、资源不足、
## 目标死亡、自身死亡、断线/切图、施法中换目标。
const SkillTable = preload("res://ui/skill_table.gd")
const Quickbar = preload("res://ui/quickbar.gd")
const Probe = preload("res://testing/playable_probe.gd")
const Flow = preload("res://testing/playable_flow.gd")
const Report = preload("res://testing/playable_report.gd")
const Config = preload("res://testing/playable_config.gd")
const Matrix = preload("res://playable_skill_matrix.gd")
const MAIN := 1000
const MOB := 2000
const MOB_B := 2001
const MOB_RACE := 101
## Warrior "Three-Way Cut": attack skill, needs a target, TargetRange 400cm.
const SKILL := 1
const SLOT := 7
class FakeClient extends Node:
signal skill_cooldown_end(skill: int)
signal quickslots_changed()
signal skills_changed()
signal entity_dead(vid: int)
signal damage(vid: int, amount: int, flag: int)
signal disconnected(reason: String)
signal entered_game()
var target := 0
var in_game := true
var entities := {
MAIN: {"vid": MAIN, "kind": 0, "ch_type": 0, "pos_cm": Vector3.ZERO, "dead": false},
MOB: {"vid": MOB, "kind": 2, "ch_type": 2, "race": MOB_RACE, "pos_cm": Vector3(300, 0, 0), "dead": false, "hp": 100},
MOB_B: {"vid": MOB_B, "kind": 2, "ch_type": 2, "race": MOB_RACE, "pos_cm": Vector3(350, 0, 0), "dead": false, "hp": 100},
}
var skills := [{"id": SKILL, "level": 5, "master": 0}]
## Every outgoing request, in order: [name, args...].
var requests: Array = []
func is_in_game() -> bool: return in_game
func get_main_vid() -> int: return MAIN
func get_entity(vid) -> Dictionary: return entities.get(int(vid), {})
func get_entities() -> Array: return entities.values()
func get_ground_items() -> Array: return []
func get_target() -> Dictionary: return {"vid": target}
func get_skills() -> Array: return skills
func get_inventory() -> Array: return []
func get_quickslots() -> Array: return []
func use_skill(id, vid) -> bool:
requests.append(["use_skill", int(id), int(vid)])
return in_game
func cast_skill(motion, rot, x, y) -> bool:
requests.append(["cast_skill", int(motion)])
return in_game
func quickslot_add(pos, type, ref) -> bool:
requests.append(["quickslot_add", pos, type, ref])
return true
## Only the NetPlay surface Quickbar/PlayableFlow touch. Range reservation itself
## (walk in, then use_skill_hook) is covered by netplay_test.
class FakeNetPlay extends Node:
var client: FakeClient
var context := {}
var reservations: Array = []
func select_target(vid: int) -> bool:
client.target = vid
return not client.get_entity(vid).is_empty()
func skill_context() -> Dictionary:
return context.merged({"target_distance": Callable(self, "skill_target_distance_cm")})
func skill_target_distance_cm(vid: int) -> float:
var e: Dictionary = client.get_entity(vid)
var me: Dictionary = client.get_entity(MAIN)
if e.is_empty() or me.is_empty():
return -1.0
return Vector3(e.pos_cm).distance_to(Vector3(me.pos_cm))
func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void:
reservations.append([vid, slot, int(range_cm)])
func is_use_skill_reserved(slot: int) -> bool:
return not reservations.is_empty() and int(reservations[-1][1]) == slot
func set_attack_key(_down: bool) -> void:
pass
class FakeApp extends Node:
var context := {}
func get_playable_context() -> Dictionary: return context
func get_playable_snapshot() -> Dictionary: return {"scene_ready": true}
class Rig:
var client: FakeClient
var net_play: FakeNetPlay
var app: FakeApp
var quickbar: Node
var ui: Control
var player: Node3D
var probe: Node
var report: RefCounted
var flow: RefCounted
var events: Array = []
var now := 1_000_000
func observed(kind: String) -> Array:
return events.filter(func(e: Array) -> bool: return e[0] == kind)
func count(name: String) -> int:
return client.requests.filter(func(r: Array) -> bool: return r[0] == name).size()
var failures := 0
var table: RefCounted
var _rigs: Array = []
func _init() -> void:
call_deferred("run")
func check(ok: bool, message: String) -> void:
if not ok:
failures += 1
printerr("FAIL: " + message)
func run() -> void:
table = SkillTable.new()
var loaded := false
for lang in ["en", "common"]:
if table.load_file(AssetRoot.path().path_join("locale/locale/%s/skilldesc.txt" % lang)):
loaded = true
break
if not loaded:
# Missing skill data makes every assertion meaningless; never report a silent pass.
check(false, "skilldesc.txt is required for playable_combat_test")
else:
check(table.target_range(SKILL) == 400 and table.is_need_target(SKILL), "fixture skill 1 is a 400cm targeted attack")
test_single_request_per_activation()
test_cooldown_holds_without_request()
test_no_target_rejected_without_request()
test_out_of_range_reserves_without_request()
test_not_enough_sp_rejected_without_request()
test_dead_target_rejected_without_request()
test_dead_self_holds_without_request()
test_disconnect_fails_case_and_stops_requests()
test_target_switch_keeps_cast_target()
test_skill_matrix()
for rig in _rigs:
rig.probe.disconnect_all()
rig.flow.clock_us = Callable()
rig.flow = null
for node in [rig.quickbar, rig.ui, rig.player, rig.probe, rig.net_play, rig.app, rig.client]:
node.free()
_rigs.clear()
print("playable_combat_test: failures=%d" % failures)
quit(1 if failures else 0)
func make_rig(repeats := 2) -> Rig:
var rig := Rig.new()
rig.client = FakeClient.new()
root.add_child(rig.client)
rig.net_play = FakeNetPlay.new()
rig.net_play.client = rig.client
rig.net_play.context = {"cur_sp": 500, "cur_hp": 500}
rig.player = Node3D.new()
root.add_child(rig.player)
rig.ui = Control.new()
root.add_child(rig.ui)
rig.quickbar = Quickbar.new()
rig.quickbar.net_play = rig.net_play
root.add_child(rig.quickbar)
rig.quickbar.setup(rig.client, table, rig.ui, func() -> Node: return rig.player)
rig.app = FakeApp.new()
rig.app.context = {"quickbar": rig.quickbar, "net_play": rig.net_play}
rig.probe = Probe.new()
root.add_child(rig.probe)
rig.probe.setup(rig.app, rig.client)
rig.probe.watch_local(rig.quickbar, "skill_cast_started", "cast_started")
rig.probe.watch_local(rig.quickbar, "skill_rejected", "skill_rejected")
rig.probe.observed.connect(func(kind: String, data: Dictionary) -> void: rig.events.append([kind, data]))
var config := {"allowed_mob_vnums": [MOB_RACE], "timeout_seconds": 900,
"skill_cases": [{"case_id": "three-way-cut", "skill_id": SKILL, "target": "enemy",
"repeats": repeats, "required_evidence": ["cast_started", "damage"]}]}
rig.report = Report.new()
rig.report.begin("combat-%d" % _rigs.size(), "full", {})
rig.report.set_required_cases(["CBT-three-way-cut"])
rig.flow = Flow.new()
rig.flow.report = rig.report
rig.flow.probe = rig.probe
rig.flow.flow = rig.app
rig.flow.client = rig.client
rig.flow.config = config
rig.flow.suite = "full"
rig.flow.allow_gameplay = true
rig.flow.clock_us = func() -> int: return rig.now
rig.probe.observed.connect(rig.flow.on_observed)
# Enter the skill stage the way the closed loop leaves PICKUP: bound main + epoch.
rig.flow._start_us = rig.now
rig.flow._main_vid = MAIN
rig.flow._bound_epoch = rig.probe.connection_epoch
rig.flow._begin_skill_case()
_rigs.append(rig)
return rig
func tick(rig: Rig, advance_us := 0) -> void:
rig.now += advance_us
rig.flow.tick()
# --- input assertions ----------------------------------------------------------
func test_single_request_per_activation() -> void:
var rig := make_rig()
check(rig.client.requests.is_empty(), "assigning the test slot sends no CG_QUICKSLOT_ADD")
tick(rig)
check(rig.count("use_skill") == 1 and rig.count("cast_skill") == 1,
"one activation sends exactly one use_skill and one cast_skill: %s" % [rig.client.requests])
check(rig.client.requests[0] == ["use_skill", SKILL, MOB], "use_skill carries the resolved target")
var casts := rig.observed("cast_started")
check(casts.size() == 1 and int(casts[0][1].target_vid) == MOB and casts[0][1].source == "local",
"cast_started is a local event bound to the cast target")
tick(rig, 100_000)
check(rig.count("use_skill") == 1, "a pending cast is not re-requested while waiting for server evidence")
func test_cooldown_holds_without_request() -> void:
var rig := make_rig()
tick(rig)
rig.client.damage.emit(MOB, 30, 1)
tick(rig)
check(rig.report.case_status("CBT-three-way-cut") == "" and rig.flow._skill_successes == 1, "first cast verified by server damage")
tick(rig, 600_000)
check(rig.count("use_skill") == 1 and rig.observed("skill_rejected").is_empty(),
"local cooldown holds the retry without a request or a rejection event")
rig.client.skill_cooldown_end.emit(SKILL)
tick(rig, 600_000)
check(rig.count("use_skill") == 2 and rig.count("cast_skill") == 2, "GC_SKILL_COOLTIME_END unlocks exactly one new request")
func test_no_target_rejected_without_request() -> void:
var rig := make_rig()
rig.client.target = 0
rig.quickbar.activate(SLOT)
var rejected := rig.observed("skill_rejected")
check(rejected.size() == 1 and rejected[0][1].code == "NEED_TARGET", "no target -> NEED_TARGET")
check(rig.client.requests.is_empty(), "NEED_TARGET sends no skill request")
func test_out_of_range_reserves_without_request() -> void:
var rig := make_rig()
rig.client.entities[MOB].pos_cm = Vector3(900, 0, 0)
rig.client.entities[MOB_B].pos_cm = Vector3(950, 0, 0)
tick(rig)
check(rig.count("use_skill") == 0 and rig.count("cast_skill") == 0, "target beyond TargetRange sends no skill request")
check(rig.net_play.reservations.size() == 1 and rig.net_play.reservations[0][0] == MOB,
"out-of-range cast becomes a MODE_USE_SKILL reservation: %s" % [rig.net_play.reservations])
check(rig.observed("skill_rejected").is_empty() and rig.observed("cast_started").is_empty(),
"reservation is neither a rejection nor a cast")
# NetPlay walked into range and fired use_skill_hook -> activate_reserved.
rig.client.entities[MOB].pos_cm = Vector3(300, 0, 0)
var global_slot: int = rig.net_play.reservations[0][1] if not rig.net_play.reservations.is_empty() else SLOT
check(bool(rig.quickbar.activate_reserved(global_slot)), "reserved skill fires once in range")
check(rig.count("use_skill") == 1 and rig.observed("cast_started").size() == 1, "in-range reserved cast sends exactly one request")
rig.client.damage.emit(MOB, 30, 1)
tick(rig)
check(rig.flow._skill_successes == 1, "the asynchronous reserved cast is credited to the case")
func test_not_enough_sp_rejected_without_request() -> void:
var rig := make_rig()
rig.net_play.context = {"cur_sp": 10, "cur_hp": 500}
tick(rig)
var rejected := rig.observed("skill_rejected")
check(rejected.size() == 1 and rejected[0][1].code == "NOT_ENOUGH_SP", "SP below GetNeedSP -> NOT_ENOUGH_SP")
check(rig.client.requests.is_empty(), "NOT_ENOUGH_SP sends no skill request")
check(rig.report.case_status("CBT-three-way-cut") == "", "resource shortage is retried, not a fixture BLOCKED")
func test_dead_target_rejected_without_request() -> void:
var rig := make_rig()
rig.client.entities[MOB].dead = true
rig.client.target = MOB
rig.quickbar.activate(SLOT)
var rejected := rig.observed("skill_rejected")
check(rejected.size() == 1 and rejected[0][1].code == "CANNOT_ATTACK", "dead target -> CANNOT_ATTACK")
check(rig.client.requests.is_empty(), "dead target sends no skill request")
tick(rig, 600_000)
check(rig.client.requests.size() == 2 and rig.client.requests[0] == ["use_skill", SKILL, MOB_B],
"flow re-targets a living monster instead of the corpse: %s" % [rig.client.requests])
func test_dead_self_holds_without_request() -> void:
var rig := make_rig()
rig.client.entities[MAIN].dead = true
tick(rig)
check(rig.client.requests.is_empty() and rig.observed("skill_rejected").is_empty(), "dead main character sends nothing")
rig.client.entities[MAIN].dead = false
rig.net_play.context = {"cur_sp": 500, "can_act": false}
tick(rig, 600_000)
check(rig.client.requests.is_empty(), "stunned/knocked-down (CANNOT_ACT) sends nothing")
func test_disconnect_fails_case_and_stops_requests() -> void:
var rig := make_rig()
tick(rig)
check(rig.count("use_skill") == 1, "cast requested before the disconnect")
rig.client.in_game = false
rig.client.disconnected.emit("socket closed")
check(rig.report.case_status("CBT-three-way-cut") == "FAIL" and rig.flow.done, "disconnect during a skill case fails that case")
# Server damage after reconnect belongs to the new epoch and the finished case.
rig.client.in_game = true
rig.client.entered_game.emit()
rig.client.damage.emit(MOB, 30, 1)
tick(rig, 600_000)
check(rig.count("use_skill") == 1 and rig.flow._skill_successes == 0, "no request or credit after the disconnect")
func test_target_switch_keeps_cast_target() -> void:
var rig := make_rig(1)
tick(rig)
check(rig.observed("cast_started").size() == 1 and int(rig.observed("cast_started")[0][1].target_vid) == MOB, "cast bound to target A")
rig.net_play.select_target(MOB_B)
rig.client.damage.emit(MOB_B, 30, 1)
tick(rig)
check(rig.flow._skill_successes == 0, "damage on the newly selected target B is not evidence for the cast on A")
rig.client.damage.emit(MOB, 30, 1)
tick(rig)
check(rig.flow._skill_successes == 1 and rig.report.case_status("CBT-three-way-cut") == "PASS",
"damage on the cast-time target A completes the case")
check(rig.count("use_skill") == 1, "switching the selection never re-sends the cast")
# --- test/playable/skill-cases.json ---------------------------------------------
func test_skill_matrix() -> void:
var committed := ProjectSettings.globalize_path("res://").path_join("../test/playable/skill-cases.json").simplify_path()
for job in Config.SKILL_MATRIX_JOBS:
var r: Dictionary = Matrix.check(committed, job)
check(r.status == "BLOCKED" and r.errors.is_empty() and r.blocked.size() == 4 and r.skill_cases.is_empty(),
"committed matrix carries no invented skill ids and blocks %s: %s" % [job, r])
var evidence := ["cast_started", "damage"]
var matrix := {"schema_version": 1, "jobs": {"WARRIOR": {
"single_target": {"status": "confirmed", "skill_id": SKILL, "target": "enemy", "required_evidence": evidence},
"area": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"},
"self_buff": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"},
"flying": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"}}}}
var ok: Dictionary = Config.skill_matrix_cases(matrix, "WARRIOR", table)
check(ok.errors.is_empty() and ok.blocked.is_empty() and ok.cases.size() == 1, "confirmed matrix resolves: %s" % [ok])
if ok.cases.size() == 1:
check(ok.cases[0].case_id == "warrior-single-target" and int(ok.cases[0].repeats) == Config.SKILL_MATRIX_REPEATS,
"matrix case defaults to 10 successful casts")
check("CBT-warrior-single-target" in Config.required_cases({"skill_cases": ok.cases}, "full"),
"matrix case becomes a required full-suite case")
var bad := [
["unconfirmed entry with a skill id", "area", {"status": "unconfirmed", "skill_id": 3}],
["skill from another job", "single_target", {"status": "confirmed", "skill_id": 32, "required_evidence": evidence}],
["fewer than 10 casts", "single_target", {"status": "confirmed", "skill_id": SKILL, "repeats": 3, "required_evidence": evidence}],
["local-only evidence", "single_target", {"status": "confirmed", "skill_id": SKILL, "required_evidence": ["cast_started", "fx_spawned"]}],
["not applicable without checklist record", "area", {"status": "not_applicable"}],
["unknown status", "flying", {"status": "skip"}],
]
for row in bad:
var mutated: Dictionary = matrix.duplicate(true)
mutated.jobs.WARRIOR[row[1]] = row[2]
check(not Config.skill_matrix_cases(mutated, "WARRIOR", table).errors.is_empty(), "matrix rejects %s" % row[0])
var none: Dictionary = matrix.duplicate(true)
none.jobs.WARRIOR.single_target = {"status": "not_applicable", "checklist_ref": "x"}
check(not Config.skill_matrix_cases(none, "WARRIOR", table).errors.is_empty(), "a job with nothing confirmed is not READY")
check(not Config.skill_matrix_cases(matrix, "SURA", table).errors.is_empty(), "a job absent from the matrix is an error")
+64 -3
View File
@@ -1,14 +1,75 @@
extends SceneTree
## Parent-runner precondition gate. Runs headless before the packaged client
## starts; it never opens a network connection or reads credentials.
##
## env:
## MT_PLAYABLE_VALIDATE_CONFIG scenario JSON (required)
## MT_PLAYABLE_SUITE playable|full|soak (default playable)
## MT_PLAYABLE_ASSETS asset root used for map/waypoint checks
## MT_PLAYABLE_SERVERLIST test-mode serverlist override (same value is
## handed to the client, so both resolve alike)
## MT_PLAYABLE_GATE_OUTPUT directory for required-cases.json / server-address.json
## exit: 0 = ready, 2 = configuration/precondition blocked.
const Config = preload("res://testing/playable_config.gd")
const ServerInfoRes = preload("res://net/serverinfo.gd")
func _init() -> void:
var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG")
var suite := OS.get_environment("MT_PLAYABLE_SUITE").strip_edges()
if suite.is_empty():
suite = "playable"
var errors: Array = []
if not (suite in Config.SUITES):
errors.append("unknown suite: %s" % suite)
var result := Config.load_file(path)
if not result.ok:
for error in result.errors:
errors.append_array(result.errors)
# A present soak block is validated for every suite: its confirmed fault proxy also
# fronts the playable exit runs that share the scenario.
if result.ok and (suite == "soak" or result.config.has("soak")):
errors.append_array(Config.validate_soak(result.config, suite == "soak").errors)
var address := {}
var bounds := {}
if result.ok:
var serverinfo := ServerInfoRes.new()
var override := OS.get_environment("MT_PLAYABLE_SERVERLIST").strip_edges()
if not override.is_empty():
if not serverinfo.load_file(override):
errors.append("MT_PLAYABLE_SERVERLIST cannot be loaded")
else:
serverinfo.load_file("res://serverlist.txt")
var resolved := Config.resolve_server(result.config, serverinfo)
errors.append_array(resolved.errors)
address = resolved.address
var assets := OS.get_environment("MT_PLAYABLE_ASSETS").strip_edges()
if assets.is_empty():
errors.append("MT_PLAYABLE_ASSETS is not set; resource preconditions cannot be checked")
else:
var resources := Config.check_resources(result.config, assets)
errors.append_array(resources.errors)
bounds = resources.bounds
if not errors.is_empty():
for error in errors:
printerr("CONFIG: " + String(error))
quit(2)
return
print("PLAYABLE CONFIG: PASS " + path)
var out_dir := OS.get_environment("MT_PLAYABLE_GATE_OUTPUT").strip_edges()
if not out_dir.is_empty():
var cases := Config.required_cases(result.config, suite)
if not _write_json(out_dir.path_join("required-cases.json"), {"schema_version": 1, "suite": suite, "cases": cases}) \
or not _write_json(out_dir.path_join("server-address.json"), address) \
or not _write_json(out_dir.path_join("map-bounds.json"), bounds):
printerr("CONFIG: cannot write gate output")
quit(2)
return
print("PLAYABLE CONFIG: PASS")
quit(0)
func _write_json(path: String, value: Variant) -> bool:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify(value, " ") + "\n")
file.close()
return true
File diff suppressed because it is too large Load Diff
+139 -331
View File
@@ -1,373 +1,181 @@
extends Node
## 首个 Mac 内测的真实联网闭环。
## 首个 Mac 内测的真实联网闭环NET-01 / CBT-01
##
## 这个脚本只负责调度生产入口并观察服务端结果:移动仍经 PlayerController
## 目标仍经 NetPlay,拾取仍经 NetPlay.pick_ground_item,技能入口由后续 CBT
## 用例接入 Quickbar。没有 `MT_PLAYABLE_ALLOW_GAMEPLAY=1` 时只登录/进场
## 观察并把后续必测项标记为 BLOCKED,不会误发游戏操作。
## 本节点只做装配:加载配置、挂 PlayableProbe、把观察转给 PlayableFlow 状态机、
## 截图、写 client-report.json 后正常退出。所有游戏操作都在 PlayableFlow 里经
## 生产入口发出;没有 MT_PLAYABLE_ALLOW_GAMEPLAY=1 时只验证登录/选角/进场
##
## env:
## MT_PLAYABLE_CONFIG 场景 JSON(不含凭据)
## MT_PLAYABLE_SUITE playable|full|soaksoak = STB-01 墙钟长跑,挂帧指标与窗口适配器)
## MT_PLAYABLE_RUN_ID 父脚本生成的 run_id
## MT_PLAYABLE_ALLOW_GAMEPLAY 1 = 允许移动/攻击/拾取/施法
## MT_TEST_REPORT client-report.json 路径
## MT_TEST_EVENTS events.jsonl 路径(默认与报告同目录)
## MT_TEST_OUTPUT 失败截图目录(默认与报告同目录)
## MTGODOT_GPUSKIN 仅作为帧指标分段标签 gpu_skin 记录
const Config = preload("res://testing/playable_config.gd")
const Report = preload("res://testing/playable_report.gd")
const Probe = preload("res://testing/playable_probe.gd")
const Flow = preload("res://testing/playable_flow.gd")
const Metrics = preload("res://testing/playable_metrics.gd")
const PlayableWindow = preload("res://testing/playable_window.gd")
## Kinds that do heavy work on the main thread; a long frame right after one is attributed to it.
const MARKED_KINDS := ["entered_game", "disconnected", "entity_spawned", "ground_item_added"]
const WAIT_WORLD_SECONDS := 60.0
const MOVE_SECONDS := 20.0
const TARGET_SECONDS := 30.0
const ATTACK_SECONDS := 90.0
const DROP_SECONDS := 30.0
const PICKUP_SECONDS := 15.0
var _flow: Node
var _client: Node
var _config: Dictionary
var _app_flow: Node
var _client: Object
var _report: RefCounted
var _probe: Node
var _machine: RefCounted
var _report_path := ""
var _events_path := ""
var _state := "boot"
var _state_since := 0.0
var _started := 0.0
var _output_dir := ""
var _watched_quickbar: Object
var _watched_fx: Object
var _finished := false
var _allow_gameplay := false
var _entered_count := 0
var _main_vid := 0
var _server_move_seen := false
var _waypoint_index := 0
var _expected_position := Vector3.ZERO
var _target_vid := 0
var _target_initial_hp := -1
var _target_damaged := false
var _target_dead := false
var _drop_vid := 0
var _drop_vnum := 0
var _pickup_before := 0
var _cases := {}
var _metrics: RefCounted
var _tick_us := 0
var _tick_max_us := 0
var _ticks := 0
var _memory_start := {}
func setup(flow: Node, m2client: Node, config_path := "") -> void:
_flow = flow
func setup(app_flow: Node, m2client: Object, config: Dictionary = {}) -> void:
_app_flow = app_flow
_client = m2client
_started = Time.get_ticks_msec() / 1000.0
_state_since = _started
_allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1"
_report_path = OS.get_environment("MT_TEST_REPORT").strip_edges()
if _report_path.is_empty():
_report_path = "user://playable-client-report.json"
_events_path = OS.get_environment("MT_TEST_EVENTS").strip_edges()
if _events_path.is_empty():
_events_path = _report_path.get_base_dir().path_join("events.jsonl")
var path := config_path if not config_path.is_empty() else OS.get_environment("MT_PLAYABLE_CONFIG")
var loaded := Config.load_file(path)
var events_path := OS.get_environment("MT_TEST_EVENTS").strip_edges()
if events_path.is_empty():
events_path = _report_path.get_base_dir().path_join("events.jsonl")
_output_dir = OS.get_environment("MT_TEST_OUTPUT").strip_edges()
if _output_dir.is_empty():
_output_dir = _report_path.get_base_dir()
var suite := OS.get_environment("MT_PLAYABLE_SUITE").strip_edges()
if suite.is_empty():
suite = "playable"
var errors: Array = []
if config.is_empty():
var loaded := Config.load_file(OS.get_environment("MT_PLAYABLE_CONFIG"))
config = loaded.config
errors = loaded.errors
else:
errors = Config.validate(config).errors
if not (suite in Config.SUITES):
errors.append("unknown suite")
_report = Report.new()
_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded.config if loaded.ok else {})
if not loaded.ok:
_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(loaded.errors))
_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), suite, config if errors.is_empty() else {}, events_path)
if not errors.is_empty():
_report.set_required_cases(["CONFIG-01"])
_report.add_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(errors))
_finish_later()
return
_config = loaded.config
_case("CONFIG-01", "PASS", "validated")
if _client == null:
_case("NET-BOOT-01", "FAIL", "M2Client was not created")
_report.set_required_cases(Config.required_cases(config, suite))
_report.add_case("CONFIG-01", "FAIL", "M2Client was not created")
_finish_later()
return
_probe = Probe.new()
_probe.name = "PlayableProbe"
add_child(_probe)
_probe.setup(_flow, _client)
_probe.observed.connect(_on_observed)
_bind("char_list", Callable(self, "_on_char_list"))
_bind("entered_game", Callable(self, "_on_entered_game"))
_bind("disconnected", Callable(self, "_on_disconnected"))
_bind("entity_moved", Callable(self, "_on_entity_moved"))
_bind("damage", Callable(self, "_on_damage"))
_bind("entity_dead", Callable(self, "_on_entity_dead"))
_bind("ground_item_added", Callable(self, "_on_ground_item_added"))
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed"))
_bind("item_picked_up", Callable(self, "_on_item_picked_up"))
_bind("inventory_changed", Callable(self, "_on_inventory_changed"))
_set_state("WAIT_LOGIN")
_record("harness_started", {"allow_gameplay": _allow_gameplay})
_probe.setup(_app_flow, _client)
_machine = Flow.new()
_machine.report = _report
_machine.probe = _probe
_machine.flow = _app_flow
_machine.client = _client
_machine.config = config
_machine.suite = suite
_machine.allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1"
_machine.capture = Callable(self, "_capture")
_machine.finished.connect(_on_machine_finished)
_probe.observed.connect(_machine.on_observed)
if suite == "soak":
_metrics = Metrics.new()
_metrics.configure({"window_seconds": 60})
_machine.metrics = _metrics
_machine.window = PlayableWindow.new(get_viewport())
_memory_start = Metrics.memory_snapshot()
_probe.observed.connect(_mark_observed)
_machine.start()
func _process(_delta: float) -> void:
if _finished:
if _machine == null or _finished:
return
var now := Time.get_ticks_msec() / 1000.0
if now - _started > float(_config.get("timeout_seconds", 120)):
_fail_current("overall timeout in state %s" % _state)
_finish_later()
_watch_scene_signals()
if _metrics != null:
var tags: Dictionary = _machine.frame_tags()
tags["gpu_skin"] = OS.get_environment("MTGODOT_GPUSKIN").strip_edges()
_metrics.set_tags(tags)
_metrics.frame()
var started := Time.get_ticks_usec()
_machine.tick()
var spent := Time.get_ticks_usec() - started
_tick_us += spent
_tick_max_us = maxi(_tick_max_us, spent)
_ticks += 1
return
match _state:
"WAIT_LOGIN":
if _entered_count > 0:
_set_state("WAIT_WORLD")
elif now - _state_since > 30.0:
_fail_case("NET-LOGIN-01", "login/character selection timed out")
_finish_later()
"WAIT_WORLD":
var snap: Dictionary = _flow.get_playable_snapshot() if _flow and _flow.has_method("get_playable_snapshot") else {}
if bool(snap.get("scene_ready", false)):
_case("NET-LOGIN-01", "PASS", "character list received")
_case("NET-SELECT-01", "PASS", "entered_game received")
_case("NET-WORLD-01", "PASS", "game scene and map are ready")
_main_vid = int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0
if _main_vid <= 0:
_fail_case("NET-WORLD-02", "main VID is invalid")
_finish_later()
elif not _allow_gameplay:
_case("GAMEPLAY-ALLOW-01", "BLOCKED", "--allow-gameplay was not supplied")
_finish_later()
else:
_begin_move()
elif now - _state_since > WAIT_WORLD_SECONDS:
_fail_case("NET-WORLD-01", "scene did not become ready")
_finish_later()
"MOVE":
if _move_reached():
if _server_move_seen:
_case("NET-MOVE-%02d" % (_waypoint_index + 1), "PASS", "server entity_moved observed")
_waypoint_index += 1
if _waypoint_index >= _config.waypoints_cm.size():
_begin_target()
else:
_send_waypoint()
else:
_block_current("local arrival without server movement evidence")
_finish_later()
elif now - _state_since > MOVE_SECONDS:
_fail_current("waypoint not reached")
_finish_later()
"TARGET":
if _target_vid > 0:
_case("NET-TARGET-01", "PASS", "allowed live monster selected")
_begin_attack()
elif now - _state_since > TARGET_SECONDS:
_fail_case("NET-TARGET-01", "no allowed live monster found")
_finish_later()
"ATTACK":
if _target_dead or (_target_damaged and _target_hp_decreased()):
if _client.has_method("get_entity") and not _client.get_entity(_target_vid).is_empty():
_case("NET-ATTACK-01", "PASS", "server damage/death evidence observed")
else:
_block_current("target result arrived after entity removal")
_stop_attack()
_set_state("DROP")
elif now - _state_since > ATTACK_SECONDS:
_stop_attack()
_fail_case("NET-ATTACK-01", "no server damage or death result")
_finish_later()
"DROP":
if _drop_vid > 0:
_case("NET-DROP-01", "PASS", "allowed ground item added by server")
_begin_pickup()
elif now - _state_since > DROP_SECONDS:
_block_current("deterministic drop fixture was not observed")
_finish_later()
"PICKUP":
if _pickup_succeeded():
_case("NET-PICKUP-01", "PASS", "item_picked_up and inventory delta observed")
_case("NET-EXIT-01", "PASS", "client test state completed")
_finish_later()
elif now - _state_since > PICKUP_SECONDS:
_fail_case("NET-PICKUP-01", "pickup result did not include inventory delta")
_finish_later()
_machine.tick()
func _bind(signal_name: String, callback: Callable) -> void:
if _client.has_signal(signal_name) and not _client.is_connected(signal_name, callback):
_client.connect(signal_name, callback)
func _mark_observed(kind: String, _data: Dictionary) -> void:
if kind in MARKED_KINDS:
_metrics.mark(kind)
func _set_state(next: String) -> void:
_state = next
_state_since = Time.get_ticks_msec() / 1000.0
_record("state", {"name": next})
func _on_observed(kind: String, payload: Dictionary) -> void:
_record(kind, payload)
func _record(kind: String, payload := {}) -> void:
if _report == null:
## Quickbar / skill fx live in GameScene and are rebuilt with it; re-bind lazily.
func _watch_scene_signals() -> void:
if _app_flow == null or not _app_flow.has_method("get_playable_context"):
return
var data: Dictionary = payload.duplicate(true)
data["monotonic_us"] = Time.get_ticks_usec()
data["run_id"] = OS.get_environment("MT_PLAYABLE_RUN_ID")
data["case_id"] = _state
_report.add_event(data, _events_path)
var context: Dictionary = _app_flow.get_playable_context()
var quickbar: Object = context.get("quickbar", null)
if quickbar != null and is_instance_valid(quickbar) and quickbar != _watched_quickbar:
_watched_quickbar = quickbar
_probe.watch_local(quickbar, "skill_cast_started", "cast_started")
_probe.watch_local(quickbar, "skill_rejected", "skill_rejected")
# EffectRegistry spawn / tree-exit boundaries (CBT-01 effect lifetime evidence).
var registry: Object = context.get("fx", null)
if registry != null and registry != _watched_fx:
_watched_fx = registry
_probe.watch_local(registry, "fx_spawned", "fx_spawned")
_probe.watch_local(registry, "fx_finished", "fx_finished")
func _on_char_list(characters: Array) -> void:
_record("char_list", {"count": characters.size()})
var slot := int(_config.get("character_slot", -1))
var found := false
for character in characters:
if int(character.get("index", -1)) == slot:
found = true
break
if not found:
_fail_case("NET-LOGIN-01", "configured character slot is absent")
_finish_later()
func _capture(case_id: String) -> String:
if DisplayServer.get_name() == "headless" or _output_dir.is_empty():
return ""
var image := get_viewport().get_texture().get_image()
if image == null or image.is_empty():
return ""
var file_name := "%s.png" % case_id.to_lower()
if image.save_png(_output_dir.path_join(file_name)) != OK:
return ""
return file_name
func _on_entered_game() -> void:
_entered_count += 1
_record("entered_game", {"count": _entered_count})
func _on_disconnected(reason: String) -> void:
_record("disconnected", {"reason": reason})
if _state != "WAIT_LOGIN" and _state != "WAIT_WORLD":
_fail_current("unexpected disconnect: %s" % reason)
_finish_later()
func _on_entity_moved(vid: int) -> void:
if vid == _main_vid:
_server_move_seen = true
_record("main_entity_moved", {"vid": vid})
func _on_damage(vid: int, amount: int, flag: int) -> void:
if vid == _target_vid and amount > 0:
_target_damaged = true
_record("target_damage", {"vid": vid, "amount": amount, "flag": flag})
func _on_entity_dead(vid: int) -> void:
if vid == _target_vid:
_target_dead = true
_record("target_dead", {"vid": vid})
func _on_ground_item_added(item: Dictionary) -> void:
var vnum := int(item.get("vnum", 0))
if _state == "DROP" and vnum in _config.get("allowed_drop_vnums", []):
_drop_vid = int(item.get("vid", 0))
_drop_vnum = vnum
_record("allowed_drop", {"vid": _drop_vid, "vnum": vnum})
func _on_ground_item_removed(vid: int) -> void:
_record("ground_item_removed", {"vid": vid})
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
if _state == "PICKUP" and vnum == _drop_vnum:
_record("item_picked_up", {"vnum": vnum, "count": count, "source": source})
func _on_inventory_changed(window: int, cell: int) -> void:
_record("inventory_changed", {"window": window, "cell": cell})
func _begin_move() -> void:
_waypoint_index = 0
_send_waypoint()
func _send_waypoint() -> void:
var point: Array = _config.waypoints_cm[_waypoint_index]
var net := Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01)
_expected_position = MapCoord.to_world(net)
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
var controller: Node = context.get("pc", null)
_server_move_seen = false
if controller == null or not controller.has_method("walk_to"):
_fail_case("NET-MOVE-%02d" % (_waypoint_index + 1), "PlayerController.walk_to is unavailable")
_finish_later()
return
controller.walk_to(_expected_position)
_set_state("MOVE")
_record("move_requested", {"waypoint_index": _waypoint_index, "x_cm": point[0], "y_cm": point[1]})
func _move_reached() -> bool:
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
var player: Node3D = context.get("player", null)
return is_instance_valid(player) and player.global_position.distance_to(_expected_position) <= 1.0
func _begin_target() -> void:
_target_vid = 0
var allowed: Array = _config.get("allowed_mob_vnums", [])
var best_distance := INF
var player_pos := Vector3.ZERO
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
var player: Node3D = context.get("player", null)
if is_instance_valid(player):
player_pos = player.global_position
for entity in _client.get_entities():
var race := int(entity.get("race", 0))
var kind := int(entity.get("kind", entity.get("ch_type", -1)))
if race not in allowed or bool(entity.get("dead", false)) or kind != 2:
continue
var pos: Variant = entity.get("pos", null)
var distance := 0.0
if pos is Vector3:
distance = player_pos.distance_to(MapCoord.to_world(pos))
if distance < best_distance:
best_distance = distance
_target_vid = int(entity.get("vid", 0))
if _target_vid > 0:
var net_play: Node = context.get("net_play", null)
if net_play == null or not net_play.has_method("select_target") or not net_play.select_target(_target_vid):
_target_vid = 0
_set_state("TARGET")
func _begin_attack() -> void:
var entity: Dictionary = _client.get_entity(_target_vid)
_target_initial_hp = int(entity.get("hp", -1))
_target_damaged = false
_target_dead = false
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
var net_play: Node = context.get("net_play", null)
if net_play == null or not net_play.has_method("set_attack_key"):
_fail_case("NET-ATTACK-01", "NetPlay.set_attack_key is unavailable")
_finish_later()
return
net_play.set_attack_key(true)
_set_state("ATTACK")
func _stop_attack() -> void:
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
var net_play: Node = context.get("net_play", null)
if net_play and net_play.has_method("set_attack_key"):
net_play.set_attack_key(false)
func _target_hp_decreased() -> bool:
if not _client.has_method("get_entity"):
return false
var e: Dictionary = _client.get_entity(_target_vid)
return not e.is_empty() and _target_initial_hp > 0 and int(e.get("hp", _target_initial_hp)) < _target_initial_hp
func _begin_pickup() -> void:
_pickup_before = _inventory_count(_drop_vnum)
var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {}
var net_play: Node = context.get("net_play", null)
if net_play == null or not net_play.has_method("pick_ground_item"):
_fail_case("NET-PICKUP-01", "NetPlay.pick_ground_item is unavailable")
_finish_later()
return
net_play.pick_ground_item(_drop_vid)
_set_state("PICKUP")
func _inventory_count(vnum: int) -> int:
if not _client.has_method("get_inventory"):
return -1
var total := 0
for item in _client.get_inventory():
if int(item.get("vnum", 0)) == vnum:
total += int(item.get("count", 0))
return total
func _pickup_succeeded() -> bool:
return _drop_vnum > 0 and _inventory_count(_drop_vnum) > _pickup_before
func _case(case_id: String, status: String, reason: String) -> void:
if _cases.has(case_id):
return
_cases[case_id] = true
_report.add_case(case_id, status, reason)
func _fail_case(case_id: String, reason: String) -> void:
_case(case_id, "FAIL", reason)
func _block_current(reason: String) -> void:
_case("BLOCK-%s" % _state, "BLOCKED", reason)
func _fail_current(reason: String) -> void:
_fail_case("FAIL-%s" % _state, reason)
func _on_machine_finished(_status: String) -> void:
_finish_later()
func _finish_later() -> void:
if _finished:
return
_finished = true
_stop_attack()
_report.write(_report_path)
call_deferred("_quit")
call_deferred("_write_and_quit")
func _quit() -> void:
func _write_and_quit() -> void:
if _machine != null:
_machine.cancel_inputs()
if _metrics != null:
_report.set_section("frame_metrics", _metrics.summary())
_report.set_section("client_memory", {"start": _memory_start, "end": Metrics.memory_snapshot(),
"note": "MEMORY_STATIC 是 Godot 分配器计数,不是进程 RSS;RSS 由父运行器按 PID 外部采样"})
_report.set_section("probe_overhead", {"sampler": Metrics.measure_overhead(),
"flow_tick": {"ticks": _ticks, "total_us": _tick_us, "max_us": _tick_max_us,
"mean_us": float(_tick_us) / maxf(1.0, _ticks)}})
if _probe != null:
_probe.disconnect_all()
if not _report.write(_report_path):
printerr("PLAYABLE: cannot write client report")
get_tree().quit(1)
return
print("PLAYABLE CLIENT: %s" % _report.finish().status)
# Normal quit path: the parent runner owns the final exit gate.
get_tree().quit(0)
+136
View File
@@ -0,0 +1,136 @@
# playable_metrics_test —— STB-01 §9.1 帧间隔/长帧指标的离线自检(注入时钟,不等真实时间)。
# godot --headless --path project --script playable_metrics_test.gd
extends SceneTree
const Metrics = preload("res://testing/playable_metrics.gd")
var _fail := 0
var _done := false
var _now := 0
func _ck(ok: bool, message: String) -> void:
if not ok:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
_run()
_ck(_done, "all metrics assertions executed")
print("playable_metrics_test: failures=%d" % _fail)
if _fail == 0:
print("PASS: playable_metrics_test")
quit(1 if _fail else 0)
func _metrics(options := {}) -> RefCounted:
var m: RefCounted = Metrics.new()
m.clock_us = func() -> int: return _now
m.configure(options)
return m
## Advance the injected clock by `ms` and close one frame.
func _frame(m: RefCounted, ms: float) -> void:
_now += int(ms * 1000.0)
m.frame()
func _run() -> void:
# Percentiles are nearest-rank over the window samples; max and counts are exact.
var p: RefCounted = _metrics({"window_seconds": 3600})
p.set_tags({"map": "m", "phase": "play", "actor_count": 8, "gpu_skin": true})
p.frame()
for i in range(1, 101):
_frame(p, float(i))
var s: Dictionary = p.summary()
var w: Dictionary = s.windows[0]
_ck(w.count == 100 and w.sampled == 100 and not w.truncated, "one window with 100 samples: %s" % [w])
_ck(w.p50_ms == 50.0 and w.p95_ms == 95.0 and w.p99_ms == 99.0 and w.max_ms == 100.0,
"nearest-rank p50/p95/p99/max: %s" % [w])
_ck(w.over_50ms == 50 and w.over_100ms == 0, ">50ms and >100ms are strict thresholds: %s" % [w])
_ck(s.print_frames == false and p.print_frames == false, "per-frame printing is off by default")
_ck(s.long_frames.size() == 50 and s.long_frames[0].ms == 51.0, "only samples above 50ms become long-frame events")
_ck(s.long_frames[0].tags == {"map": "m", "phase": "play", "actor_count": "8", "gpu_skin": "true"},
"long frames carry map/phase/actor_count/gpu_skin tags: %s" % [s.long_frames[0].tags])
_ck(s.frames == 100, "first frame() only arms the clock")
# Fixed-size ring: a window longer than the capacity keeps exact counters but
# bounded sample storage, and says so.
var ring: RefCounted = _metrics({"capacity": 64, "window_seconds": 3600, "max_long_frames": 8, "max_markers": 4})
ring.set_tags({"phase": "play"})
ring.frame()
for i in 10000:
_frame(ring, 120.0 if i == 17 else (60.0 if i % 1000 == 0 else 16.0))
if i % 100 == 0:
ring.mark("entity_batch", {"count": i})
_ck(ring.stored_sample_count() <= 64, "ring buffer never grows past capacity (%d)" % ring.stored_sample_count())
var rs: Dictionary = ring.summary()
var rw: Dictionary = rs.windows[0]
_ck(rw.count == 10000 and rw.sampled == 64 and rw.truncated, "overlong window is marked truncated: %s" % [rw])
_ck(rw.max_ms == 120.0 and rw.over_100ms == 1 and rw.over_50ms == 11, "counters stay exact beyond capacity: %s" % [rw])
_ck(rs.long_frames.size() == 8 and rs.long_frames_dropped == 3, "long-frame events are bounded with a drop count")
_ck(rs.markers.size() == 4 and rs.markers_dropped == 96, "markers are bounded with a drop count")
_ck(ring.long_unattributed_by_phase() == {"play": 11} and rs.long_frames.size() == 8,
"unattributed counts stay exact while the long-frame list is bounded: %s" % [ring.long_unattributed_by_phase()])
# Wall-clock windows close on the injected clock, not on a frame count.
var timed: RefCounted = _metrics({"window_seconds": 1, "max_windows": 3})
timed.set_tags({"phase": "play"})
timed.frame()
for i in 400:
_frame(timed, 16.0)
var ts: Dictionary = timed.summary()
_ck(ts.windows.size() == 3 and ts.windows_dropped >= 3, "closed windows are bounded (%d kept, %d dropped)" % [ts.windows.size(), ts.windows_dropped])
_ck(ts.windows.all(func(x: Dictionary) -> bool: return x.end_ms - x.start_ms <= 1016), "each window spans about one wall-clock second")
# A tag change closes the window and opens a separate segment; segments are capped.
var seg: RefCounted = _metrics({"max_segments": 2, "window_seconds": 3600})
seg.set_tags({"phase": "loading", "map": "a"})
seg.frame()
_frame(seg, 300.0)
seg.set_tags({"phase": "play", "map": "a"})
_frame(seg, 16.0)
seg.set_tags({"phase": "play", "map": "b"})
_frame(seg, 16.0)
var ss: Dictionary = seg.summary()
_ck(ss.windows.size() == 3 and ss.windows[0].tags.phase == "loading", "tag changes split windows: %s" % [ss.windows])
_ck(ss.segments.size() == 2 and ss.unsegmented_frames == 1, "segment table is capped: %s" % [ss.segments])
_ck(ss.segments[0].max_ms == 300.0 and ss.segments[0].over_100ms == 1, "loading segment is reported separately")
_ck(seg.severe_by_phase() == {"loading": 1}, "severe counts by phase: %s" % [seg.severe_by_phase()])
# Markers from the frame that was measured (and the one before it) attribute a long frame.
var att: RefCounted = _metrics({"window_seconds": 3600})
att.set_tags({"phase": "play"})
att.frame()
_frame(att, 16.0)
att.mark("model_build", {"race": 2301, "ms": 70.0})
_frame(att, 16.0)
_frame(att, 80.0)
_frame(att, 16.0)
_frame(att, 16.0)
_frame(att, 75.0)
var at: Dictionary = att.summary()
_ck(at.long_frames.size() == 2 and at.long_frames[0].markers == ["model_build"], "long frame attributed to the recent marker: %s" % [at.long_frames])
_ck(at.long_frames[1].markers.is_empty(), "later long frame without a marker stays unattributed")
_ck(att.unattributed_long_frames().size() == 1, "unattributed long frames are listed for analysis")
_ck(att.long_unattributed_by_phase() == {"play": 1} and at.long_unattributed_by_phase == {"play": 1},
"unattributed long frames are counted per phase: %s" % [att.long_unattributed_by_phase()])
_ck(at.markers[0].detail == {"race": 2301, "ms": 70.0}, "marker detail keeps only whitelisted keys")
att.mark("model_build", {"name": "Hero", "count": 2})
_ck(not att.summary().markers[-1].detail.has("name"), "non-whitelisted marker detail is dropped")
_ck([0, 1, 8, 9, 16, 17, 64, 65].map(func(n: int) -> String: return Metrics.actor_bucket(n))
== ["0", "1-8", "1-8", "9-16", "9-16", "17-32", "33-64", "65+"], "actor counts are bucketed")
# §9.3: the same interaction repeated 3 times with >100ms in at least 2 blocks.
_ck(Metrics.repeated_severe({"ATTACK": [true, false, true], "MOVE": [true, false, false, true], "DROP": [false, true, true]})
== ["ATTACK", "DROP"], "2-of-3 consecutive repetitions with a >100ms frame are flagged")
_ck(Metrics.repeated_severe({"ATTACK": [true, true]}).is_empty(), "fewer than 3 repetitions cannot be judged yet")
# Memory: MEMORY_STATIC is recorded; GPU memory is never reported as 0.
var mem: Dictionary = Metrics.memory_snapshot()
_ck(mem.memory_static_kib is int and mem.memory_static_kib > 0, "MEMORY_STATIC in KiB: %s" % [mem])
_ck(mem.gpu_memory_kib == null and mem.gpu_memory_status == "unavailable", "GPU memory is null/unavailable")
_ck(mem.godot_video_mem_kib == null or int(mem.godot_video_mem_kib) > 0, "Godot video allocation is null when unknown, not 0")
# Probe cost is measured, not assumed.
var cost: Dictionary = Metrics.measure_overhead(2000)
_ck(cost.iterations == 2000 and cost.per_sample_us > 0.0 and cost.per_sample_us < 1000.0, "per-sample probe cost measured: %s" % [cost])
_done = true
+64
View File
@@ -0,0 +1,64 @@
extends SceneTree
## CBT-01 技能矩阵检查:读取 test/playable/skill-cases.json(或 --matrix 指定的本地副本),
## 按职业校验并输出可直接放入场景配置的 skill_cases。只读数据,不连网络、不读凭据。
##
## godot --headless --path project --script playable_skill_matrix.gd -- --job WARRIOR [--matrix PATH]
##
## stdout: 一行 "SKILL_MATRIX {json}"。exit 0 = READY1 = 矩阵错误,2 = 存在未确认条目(BLOCKED)。
const Config = preload("res://testing/playable_config.gd")
const SkillTable = preload("res://ui/skill_table.gd")
func _init() -> void:
var args := OS.get_cmdline_user_args()
var job := ""
var matrix_path := ProjectSettings.globalize_path("res://").path_join("../test/playable/skill-cases.json").simplify_path()
var i := 0
while i < args.size():
match args[i]:
"--job":
i += 1
job = String(args[i]).to_upper() if i < args.size() else ""
"--matrix":
i += 1
matrix_path = args[i] if i < args.size() else ""
"--help":
print("usage: playable_skill_matrix.gd -- --job WARRIOR|ASSASSIN|SURA|SHAMAN [--matrix PATH]")
quit(0)
return
_:
printerr("SKILL_MATRIX: unknown argument %s" % args[i])
quit(1)
return
i += 1
var result := check(matrix_path, job)
print("SKILL_MATRIX " + JSON.stringify(result))
quit({"READY": 0, "BLOCKED": 2}.get(result.status, 1))
static func check(matrix_path: String, job: String) -> Dictionary:
var out := {"status": "FAIL", "job": job, "errors": [], "blocked": [], "not_applicable": [], "skill_cases": []}
var file := FileAccess.open(matrix_path, FileAccess.READ)
if file == null:
out.errors.append("skill matrix cannot be opened")
return out
var parsed: Variant = JSON.parse_string(file.get_as_text())
if not (parsed is Dictionary):
out.errors.append("skill matrix is not a JSON object")
return out
var table := SkillTable.new()
var loaded := false
for lang in ["en", "common"]:
if table.load_file(AssetRoot.path().path_join("locale/locale/%s/skilldesc.txt" % lang)):
loaded = true
break
var resolved := Config.skill_matrix_cases(parsed, job, table if loaded else null)
out.errors = resolved.errors
if not loaded:
out.errors.append("skilldesc.txt is unavailable; skill ownership cannot be checked")
out.blocked = resolved.blocked
out.not_applicable = resolved.not_applicable
out.skill_cases = resolved.cases
if out.errors.is_empty():
out.status = "BLOCKED" if not out.blocked.is_empty() else "READY"
return out
+6 -4
View File
@@ -363,9 +363,11 @@ func _process(dt: float) -> void:
_mobile_active = true
_is_going = false
_last_wasd = Vector2.ZERO
# GameCamera 位于 head + (sin yaw, cos yaw)*dist 并看向 head:视线的水平前方是
# -(sin yaw, cos yaw),屏幕右 = 前方 × UP。
var mobile_yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0)
var mobile_fwd := Vector3(sin(mobile_yaw), 0, cos(mobile_yaw))
var mobile_right := Vector3(mobile_fwd.z, 0, -mobile_fwd.x)
var mobile_fwd := -Vector3(sin(mobile_yaw), 0, cos(mobile_yaw))
var mobile_right := Vector3(-mobile_fwd.z, 0, mobile_fwd.x)
wish = (mobile_fwd * -mobile_axis.y + mobile_right * mobile_axis.x).normalized()
elif wasd != Vector2.ZERO:
_mobile_active = false
@@ -379,8 +381,8 @@ func _process(dt: float) -> void:
_last_wasd = wasd
_is_going = false # 键盘覆盖点地(NEW_MoveToDirectionm_isGoing = FALSE
var yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0)
var fwd := Vector3(sin(yaw), 0, cos(yaw))
var right := Vector3(fwd.z, 0, -fwd.x)
var fwd := -Vector3(sin(yaw), 0, cos(yaw))
var right := Vector3(-fwd.z, 0, fwd.x)
wish = (fwd * -wasd.y + right * wasd.x).normalized()
elif _is_going:
_mobile_active = false
+42
View File
@@ -5,6 +5,7 @@
extends SceneTree
const PlayerCtl = preload("res://player_controller.gd")
const GameCam = preload("res://game_camera.gd")
var _fail := 0
func _ck(c: bool, m: String) -> void:
@@ -142,3 +143,44 @@ func _run() -> void:
"mounted: turn clamped to 300 deg/s in one 50ms step (%.3f rad)" % pc.player.rotation.y)
_ck(pc.player.position.z < -0.05, "mounted: still translates toward Dst while turning")
pc.rotation_speed_deg = pc.ROT_SPEED_DEFAULT_DEG
# 8) 相机相对移动:W / 摇杆上 = 远离相机(沿视线),D / 摇杆右 = 屏幕右。
# GameCamera 摆在 head + (sin yaw, ·, cos yaw)*dist 并看向 head,前方取真实相机几何。
var cam: Camera3D = GameCam.new()
get_root().add_child(cam)
cam.target = pc.player
cam.yaw = deg_to_rad(30.0)
pc.camera = cam
pc.player.rotation.y = 0.0
var off: Vector3 = cam._desired_pos(true) - (pc.player.global_position + cam.head_offset)
var away := -Vector3(off.x, 0, off.z).normalized()
var screen_right := away.cross(Vector3.UP)
for c in [["mobile axis up", Vector2(0, -1), away], ["mobile axis right", Vector2(1, 0), screen_right]]:
pc.player.position = Vector3.ZERO
pc._is_going = false
pc.set_mobile_axis(c[1])
pc._process(0.1)
pc.clear_mobile_input()
var moved := Vector3(pc.player.position.x, 0, pc.player.position.z)
_ck(moved.length() > 0.05 and moved.normalized().dot(c[2]) > 0.95,
"%s moves camera-relative (want %s, moved %s)" % [c[0], c[2], moved])
for c in [[KEY_W, "W", away], [KEY_S, "S", -away], [KEY_D, "D", screen_right]]:
pc.player.position = Vector3.ZERO
pc._is_going = false
var down := InputEventKey.new()
down.keycode = c[0]
down.physical_keycode = c[0]
down.pressed = true
Input.parse_input_event(down)
Input.flush_buffered_events()
_ck(Input.is_key_pressed(c[0]), "synthetic %s press reaches Input" % c[1])
pc._process(0.1)
var up := down.duplicate()
up.pressed = false
Input.parse_input_event(up)
Input.flush_buffered_events()
var moved := Vector3(pc.player.position.x, 0, pc.player.position.z)
_ck(moved.length() > 0.05 and moved.normalized().dot(c[2]) > 0.95,
"key %s moves camera-relative (want %s, moved %s)" % [c[1], c[2], moved])
pc.camera = null
cam.free()
+32 -2
View File
@@ -56,6 +56,9 @@ var arrow_count := -1 # 装备箭袋数量;-1 未知/无限,
var slot_cd_end := 0.0 # 该槽本地冷却结束时刻(Time.get_ticks_msec()/1000
var cur_sp := -1 # GetStatus(POINT_SP)-1 = 未知(不判 NOT_ENOUGH_SP
var cur_hp := -1 # GetStatus(POINT_HP)-1 = 未知(不判 NOT_ENOUGH_HP
var bow_distance := 0 # GetStatus(POINT_BOW_DISTANCE) —— __GetSkillTargetRange 加成
## vid -> 主角到目标的距离 cm(已含 IS_HUGE_RACE 200 修正);<0 = 未知。未注入则不判射程。
var target_distance := Callable()
func setup(m2client: Object, skill_table: RefCounted) -> void:
client = m2client
@@ -69,6 +72,24 @@ func _now() -> float:
func _ok(target_vid := 0) -> Dictionary:
return {"ok": true, "code": "OK", "target_vid": target_vid}
# __GetSkillTargetRange —— PythonPlayerSkill.cpp:390
func _skill_target_range(skill_id: int) -> int:
return int(table.target_range(skill_id)) + bow_distance * 100
# 射程外:不施法,交给调用方 __ReserveUseSkill(走近后再 __UseSkill)。
# charge = 参考端「蓄力技且未预约时先 __SendUseSkill(slot, 0)」。
func _out_of_range(skill_id: int, tvid: int) -> Dictionary:
var range_cm := _skill_target_range(skill_id)
if range_cm <= 0 or not target_distance.is_valid():
return {}
var distance := float(target_distance.call(tvid))
if distance < 0.0 or distance < float(range_cm):
return {}
var r := _no("RESERVED", tvid)
r["range_cm"] = range_cm
r["charge"] = bool(table.is_charge_skill(skill_id))
return r
func _no(code: String, target_vid := 0) -> Dictionary:
return {"ok": false, "code": code, "target_vid": target_vid}
@@ -358,8 +379,8 @@ func use_skill(skill_id: int, is_active := false) -> Dictionary:
return _resolve_target(skill_id)
# __UseSkill 里的目标分支(PythonPlayerSkill.cpp:520-650)。
# 距离 / __ReserveUseSkill / 扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9
# 这里只判目标「类型」是否合法并回传解析到的 vid
# 判目标「类型」是否合法并回传解析到的 vid;射程外回 RESERVED(带 range_cm / charge
# 由调用方 __ReserveUseSkill。扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9
func _resolve_target(skill_id: int) -> Dictionary:
var main_vid := _main_vid()
var tvid := 0
@@ -389,6 +410,11 @@ func _resolve_target(skill_id: int) -> Dictionary:
return _ok(main_vid)
# 非自己:结盟技打友方(不可攻击的 PC)合法;打敌人不合法
if _entity_kind(te) == 0 and not _is_attackable(te):
# PythonPlayerSkill.cpp:560:友方 PC 在射程外 -> __ReserveUseSkill(无蓄力预发)。
var far := _out_of_range(skill_id, tvid)
if not far.is_empty():
far["charge"] = false
return far
return _ok(tvid)
if table.can_use_for_me(skill_id):
return _ok(main_vid) # 目标非法 -> 回落自身
@@ -398,6 +424,10 @@ func _resolve_target(skill_id: int) -> Dictionary:
if bool(te.get("in_safe", false)):
return _no("CANNOT_ATTACK_ENEMY_IN_SAFE_AREA")
if _is_attackable(te):
# __ProcessEnemySkillTargetRangePythonPlayerSkill.cpp:395
var far := _out_of_range(skill_id, tvid)
if not far.is_empty():
return far
return _ok(tvid)
return _no("CANNOT_ATTACK")
+29
View File
@@ -56,6 +56,8 @@ func _reset(g: RefCounted) -> void:
g.slot_cd_end = 0.0
g.cur_hp = -1
g.cur_sp = -1
g.bow_distance = 0
g.target_distance = Callable()
func _run() -> void:
var assets := AssetRoot.path()
@@ -227,6 +229,33 @@ func _run() -> void:
fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 1} # NPC
_ck(g.use_skill(1).code == "CANNOT_ATTACK", "(d) 目标不可攻击 -> CANNOT_ATTACK")
fc.entities[2000] = {"kind": 2}
# __ProcessEnemySkillTargetRangedistance >= TargetRange(+弓距) -> 预约,不施法
var range1: int = st.target_range(1)
if range1 > 0:
var dist := {"cm": float(range1)}
g.target_distance = func(_vid: int) -> float: return float(dist.cm)
var far := g.use_skill(1)
_ck(not far.ok and far.code == "RESERVED" and far.target_vid == 2000 and far.range_cm == range1,
"(d) 目标恰在射程边界 -> RESERVED(range=%d): %s" % [range1, far])
_ck(PlayerSkill.is_silent_code("RESERVED"), "RESERVED 不弹 OnCannotUseSkill")
dist.cm = range1 - 1.0
_ck(g.use_skill(1).ok, "(d) 射程内 1cm -> ok")
g.bow_distance = 2
dist.cm = range1 + 150.0
_ck(g.use_skill(1).ok, "(d) POINT_BOW_DISTANCE*100 计入射程")
dist.cm = -1.0
_ck(g.use_skill(1).ok, "(d) 距离未知 -> 不预约")
_reset(g)
# 蓄力技在射程外要求调用方先发 use_skill(slot, 0)
for id in st._by_id.keys():
if st.is_charge_skill(int(id)) and st.target_range(int(id)) > 0 and st.is_need_target(int(id)):
fc.skills.append({"id": int(id), "level": 1, "master": 0})
g.target_distance = func(_vid: int) -> float: return 100000.0
var rc := g.use_skill(int(id))
_ck(rc.code != "RESERVED" or rc.charge, "(d) 蓄力技 %d 射程外 -> RESERVED charge=true: %s" % [id, rc])
fc.skills.pop_back()
_reset(g)
break
# 结盟技 109 CureCAN_USE_FOR_ME|ONLY_FOR_ALLIANCE
if st.has(109) and st.is_only_for_alliance(109) and st.can_use_for_me(109):
_reset(g)
+35
View File
@@ -0,0 +1,35 @@
extends SceneTree
## Prints the address AppFlow would connect to, resolved through the same
## ServerInfo (built-in default + res://serverlist.txt). Used by
## script/live_smoke_test.sh so the port precheck can never drift from the
## client connect target. Never opens a connection or reads credentials.
##
## env: MT_SERVER_INDEX (default 0), MT_SERVER_CHANNEL (default: the first
## channel listed for that server, matching the login screen default).
## stdout: one line "SERVER_ADDRESS {json}". exit 2 when unresolvable.
const ServerInfoRes = preload("res://net/serverinfo.gd")
func _init() -> void:
var serverinfo := ServerInfoRes.new()
serverinfo.load_file("res://serverlist.txt")
var index := int(OS.get_environment("MT_SERVER_INDEX")) if OS.has_environment("MT_SERVER_INDEX") else 0
var server: Dictionary = serverinfo.server(index)
if server.is_empty():
printerr("SERVER_ADDRESS: server index %d is not in serverinfo" % index)
quit(2)
return
var channels: Array = server.get("channels", [1])
var channel := int(channels[0]) if not channels.is_empty() else 1
if OS.has_environment("MT_SERVER_CHANNEL"):
channel = int(OS.get_environment("MT_SERVER_CHANNEL"))
if not channels.has(channel):
printerr("SERVER_ADDRESS: channel %d is not listed for server %d" % [channel, index])
quit(2)
return
var address: Dictionary = serverinfo.address(index, channel)
address["server_index"] = index
address["channel"] = channel
print("SERVER_ADDRESS " + JSON.stringify(address))
quit(0)
+3 -1
View File
@@ -33,7 +33,9 @@ func run() -> void:
"race %d: hair is from a different resource family" % race)
for motion in ["wait", "walk", "run", "attack", "damage", "dead"]:
pv.set_anim_state(motion)
check(String(pv.anim.get("anim_path")).get_file() == motion + ".msa",
# damage is registered as two equal-weight variants (damage / damage_1).
var variants: Array = PlayerView.REACTION_FILES.get(motion, [motion])
check(String(pv.anim.get("anim_path")).get_file().get_basename() in variants,
"race %d: missing %s motion" % [race, motion])
for fraction in [0.0, 0.5, 1.0]:
pv.anim.set("time", float(pv.anim.call("get_duration")) * fraction)
+90 -9
View File
@@ -1,15 +1,19 @@
# Offline real-resource stress/viewport report; NOT 40250 visual parity or
# server reconnect acceptance. Run with a renderer, --quit-after 3600.
# suite=offline_synthetic: FakeClient + real assets. STB-01 live numbers only come
# from the networked soak suite (script/playable_soak.sh); never merge the two.
extends SceneTree
const GameScene = preload("res://game_scene.gd")
const Fixtures = preload("res://gamescene_test.gd")
const PlayerView = preload("res://ui/player_view.gd")
const MobView = preload("res://ui/mob_view.gd")
const Metrics = preload("res://testing/playable_metrics.gd")
class ScenarioClient extends Fixtures.FakeClient:
signal warp(pos: Vector3, same_server: bool)
signal world_reset()
var failures: Array[String] = []
var output := ""
var metrics: RefCounted
func check(ok: bool, label: String) -> void:
if not ok:
failures.append(label)
@@ -27,8 +31,13 @@ func run() -> void:
DirAccess.make_dir_recursive_absolute(output)
root.size = Vector2i(1280, 720)
var report := {"scope": "offline real maps, CPU/GPU animated crowd and viewport layout; no server writes or 40250 parity",
"suite": "offline_synthetic", "suite_note": "离线合成:FakeClient + 真实资源,无服务器;不能代替 soak 实网 suite",
"engine": Engine.get_version_info(), "backend": DisplayServer.get_name(), "samples": [],
"sample_frames": sample_frames, "model_builds": []}
"sample_frames": sample_frames, "model_builds": [], "viewports": [],
"percentile": "nearest-rank over window-frame intervals",
"cold_start_note": "进程冷启动/系统缓存状态未知;测试不清空系统缓存"}
metrics = Metrics.new()
metrics.configure({"window_seconds": 3600})
var client := ScenarioClient.new()
root.add_child(client)
var scene := GameScene.new()
@@ -41,24 +50,32 @@ func run() -> void:
for i in 30: await process_frame
check(scene._model_built and scene._map_loaded(), "A1 main character and world loaded")
var centre: Vector3 = scene.player.position
report["probe_overhead"] = await _probe_overhead(sample_frames)
metrics.reset_frame_clock()
metrics.frame()
for mode in ["0", "1"]:
OS.set_environment("MTGODOT_GPUSKIN", mode)
var crowd := Node3D.new()
scene.add_child(crowd)
for count in [1, 8, 16]:
_tags("OutdoorA1", "loading", count, mode == "1")
while crowd.get_child_count() < count:
var index := crowd.get_child_count()
var view := PlayerView.new()
var build_start := Time.get_ticks_usec()
check(view.build(AssetRoot.path(), index % 8), "crowd race %d build" % (index % 8))
report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8,
"build_ms": (Time.get_ticks_usec() - build_start) / 1000.0,
var build_ms := (Time.get_ticks_usec() - build_start) / 1000.0
metrics.mark("model_build", {"race": index % 8, "ms": build_ms})
report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8, "build_ms": build_ms,
"note": "synchronous build only; OS/shader caches may already be warm"})
crowd.add_child(view)
view.position = centre + Vector3((index % 4 - 1.5) * 2, 0, (index / 4 + 1) * 2)
view.position.y = scene.world.sample_height(view.position.x, view.position.z)
view.set_anim_state("run")
for i in 12: await process_frame
for i in 12:
await process_frame
metrics.frame()
_tags("OutdoorA1", "gameplay", count, mode == "1")
var times: Array[float] = []
var switches: Array[float] = []
var memory_start := Performance.get_monitor(Performance.MEMORY_STATIC)
@@ -69,7 +86,9 @@ func run() -> void:
var state: String = ["run", "attack", "wait"][(i / 30) % 3]
for view in crowd.get_children(): view.set_anim_state(state)
switches.append((Time.get_ticks_usec() - switch_start) / 1000.0)
metrics.mark("motion_switch", {"state": state, "count": count, "ms": switches[-1]})
await process_frame
metrics.frame()
var now := Time.get_ticks_usec()
times.append((now - previous) / 1000.0)
previous = now
@@ -79,21 +98,32 @@ func run() -> void:
"finite posed bounds after run/attack/wait blend")
times.sort()
switches.sort()
var sorted := PackedFloat32Array(times)
report.samples.append({"gpu_skin": mode == "1", "extra_players": count,
"median_ms": times[sample_frames / 2], "p95_ms": times[int((sample_frames - 1) * 0.95)], "max_ms": times[-1],
"median_ms": Metrics.percentile(sorted, 0.5), "p95_ms": Metrics.percentile(sorted, 0.95),
"p99_ms": Metrics.percentile(sorted, 0.99), "max_ms": sorted[-1],
"p99_note": "nearest-rank; below 100 samples p99 equals max",
"over_50ms": times.filter(func(ms: float) -> bool: return ms > Metrics.LONG_MS).size(),
"over_100ms": times.filter(func(ms: float) -> bool: return ms > Metrics.SEVERE_MS).size(),
"motion_switches": switches.size(), "motion_switch_max_ms": switches[-1],
"memory_static_start": memory_start, "memory_static_end": Performance.get_monitor(Performance.MEMORY_STATIC),
"clip_cache": Metin2AnimPlayer.get_clip_cache_stats(),
"note": "%d window-frame intervals with repeated motion transitions; not GPU timestamp, RSS or FPS guarantee" % sample_frames})
print("SCENARIO_SAMPLE gpu=%s count=%d frames=%d max_ms=%.3f" % [mode, count, sample_frames, times[-1]])
check(report.samples[-1].p99_ms <= report.samples[-1].max_ms and report.samples[-1].p99_ms > 0.0, "p99 recorded for gpu=%s count=%d" % [mode, count])
print("SCENARIO_SAMPLE gpu=%s count=%d frames=%d p99_ms=%.3f max_ms=%.3f" % [mode, count, sample_frames, report.samples[-1].p99_ms, times[-1]])
crowd.free()
await process_frame
metrics.frame()
OS.set_environment("MTGODOT_GPUSKIN", "0")
var mobs := Node3D.new()
scene.add_child(mobs)
_tags("OutdoorA1", "loading", 3, false)
for race in [101, 110, 20001]:
var view := MobView.new()
if not view.build(AssetRoot.path(), null, race):
var build_start := Time.get_ticks_usec()
var built: bool = view.build(AssetRoot.path(), null, race)
metrics.mark("model_build", {"race": race, "ms": (Time.get_ticks_usec() - build_start) / 1000.0})
if not built:
check(false, "NPC/monster build race=%d" % race)
view.free()
continue
@@ -102,9 +132,17 @@ func run() -> void:
view.position.y = scene.world.sample_height(view.position.x, view.position.z)
view.set_anim_state("wait")
check(not view.model.get_visual_aabb().size.is_zero_approx(), "NPC/monster real geometry")
_tags("OutdoorA1", "gameplay", 3, false)
for size in [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440)]:
root.size = size
for i in 5: await process_frame
metrics.mark("viewport_resize", {"size": "%dx%d" % [size.x, size.y]})
for i in 5:
await process_frame
metrics.frame()
# Logical viewport and physical window pixels are both recorded; Retina scale is not a size error.
report.viewports.append({"logical": [size.x, size.y], "visible": [int(root.get_visible_rect().size.x), int(root.get_visible_rect().size.y)],
"window_physical": [DisplayServer.window_get_size().x, DisplayServer.window_get_size().y],
"screen_scale": DisplayServer.screen_get_scale()})
if scene.quickbar and scene.quickbar._root:
var rect: Rect2 = scene.quickbar._root.get_global_rect()
check(Rect2(Vector2.ZERO, root.get_visible_rect().size).encloses(rect), "quickbar inside viewport %s" % size)
@@ -114,11 +152,26 @@ func run() -> void:
# Late server location update exercises the production map-correction path.
client.ents[1000].pos_cm = Vector2(959109, 269267)
client.ents[1000].pos = Vector3(9591.09, 0, -2692.67)
_tags("OutdoorC1", "loading", 3, false)
metrics.mark("map_switch", {"map": "OutdoorC1"})
client.world_reset.emit()
client.warp.emit(client.ents[1000].pos, false)
for i in 60: await process_frame
for i in 60:
await process_frame
metrics.frame()
check(scene.map_path == "OutdoorC1/metin2_map_c1" and scene._map_loaded(), "A1 to C1 map correction")
report["final_map"] = scene.map_path
var summary: Dictionary = metrics.summary()
report["frame_metrics"] = summary
report["unattributed_long_frames"] = metrics.unattributed_long_frames().size()
report["memory"] = Metrics.memory_snapshot()
check(int(summary.frames) > sample_frames * 6 and not summary.segments.is_empty() and not summary.markers.is_empty(), "frame metrics recorded with markers")
var phases := {}
for row in summary.segments: phases[row.tags.phase] = true
check(phases.has("loading") and phases.has("gameplay"), "loading and gameplay are separate segments: %s" % [phases.keys()])
check(report.memory.gpu_memory_kib == null, "GPU memory is reported unavailable, never 0")
print("SCENARIO_METRICS frames=%d long=%d unattributed=%d severe=%s" % [summary.frames, summary.long_frames.size(),
report.unattributed_long_frames, summary.severe_by_phase])
scene.free()
client.free()
await process_frame
@@ -127,3 +180,31 @@ func run() -> void:
FileAccess.open(output.path_join("report.json"), FileAccess.WRITE).store_string(JSON.stringify(report, "\t"))
print("rendering_scenario_test: failures=%d report=%s" % [failures.size(), output])
quit(1 if not failures.is_empty() else 0)
func _tags(map: String, phase: String, actors: int, gpu_skin: bool) -> void:
metrics.set_tags({"map": map, "phase": phase, "actor_count": Metrics.actor_bucket(actors), "gpu_skin": gpu_skin})
## Short alternating baselines with the probe off and on (same scene, same frames),
## plus the per-sample CPU cost; the difference is reported, never assumed zero.
func _probe_overhead(frames: int) -> Dictionary:
var rounds: Array = []
var probe: RefCounted = Metrics.new()
probe.configure({"window_seconds": 3600})
probe.set_tags({"map": "OutdoorA1", "phase": "probe", "actor_count": "0", "gpu_skin": false})
for i in 4:
var enabled := i % 2 == 1
var times := PackedFloat32Array()
var previous := Time.get_ticks_usec()
for f in frames:
await process_frame
if enabled:
probe.frame()
var now := Time.get_ticks_usec()
times.append((now - previous) / 1000.0)
previous = now
times.sort()
rounds.append({"probe": enabled, "frames": frames, "p50_ms": Metrics.percentile(times, 0.5),
"p99_ms": Metrics.percentile(times, 0.99), "max_ms": times[-1]})
var off: Array = rounds.filter(func(r: Dictionary) -> bool: return not r.probe)
var on: Array = rounds.filter(func(r: Dictionary) -> bool: return r.probe)
return {"rounds": rounds, "cpu": Metrics.measure_overhead(20000),
"p50_delta_ms": (on[0].p50_ms + on[1].p50_ms - off[0].p50_ms - off[1].p50_ms) / 2.0,
"note": "frame pacing noise can exceed the probe cost; per_sample_us is the direct CPU cost"}
+62
View File
@@ -8,6 +8,17 @@ const Quickbar = preload("res://ui/quickbar.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
const UiManager = preload("res://ui/ui_manager.gd")
# __ReserveUseSkill 的最小 NetPlay 面:射程上下文 + 预约记录。
class FakeRangeNetPlay extends Node:
var distance_cm := 100000.0
var reservations := []
func skill_context() -> Dictionary:
return {"bow_distance": 2, "target_distance": func(_vid: int) -> float: return distance_cm}
func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void:
reservations.append([vid, slot, int(range_cm)])
func is_use_skill_reserved(slot: int) -> bool:
return not reservations.is_empty() and int(reservations[-1][1]) == slot
class FakeClient extends Node:
signal skills_changed()
signal skill_cooldown_end(skill: int)
@@ -178,6 +189,26 @@ func _run() -> void:
_ck(st.is_fan_range(91), "skill 91 IsFanRangebipabu FAN_RANGE")
_ck(st.target_range(91) == 1800, "target_range(91) == 1800bipabu.msk Range")
_ck(st.target_count(91, 20) == 0, "target_count(91) == 0bipabu 无 TargetCountFormula -> 仅主目标)")
# LoadLocaleDataRegisterSkillDesc 后 RegisterSkillTablePythonApplication.cpp:1049-1055)。
# RegisterSkill(.msk) 在参考端没有调用点,dwTargetRange 只来自 SkillTable.txt 的
# TARGET_RANGE 列;GetTargetRangePythonSkill.cpp:1086)对 MELEE/CHARGE_ATTACK 固定 170。
var tbl := SkillTable.new()
for lang in ["en", "common"]:
if tbl.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)):
break
var table_loaded: bool = tbl.has_method("load_table") \
and bool(tbl.call("load_table", assets.path_join("locale/locale/common/skilltable.txt")))
_ck(table_loaded, "load_table(common/skilltable.txt) succeeds")
if table_loaded:
_ck(tbl.target_range(1) == 0, "SkillTable TARGET_RANGE 0 overrides samyeon.msk Range: %d" % tbl.target_range(1))
_ck(tbl.target_range(47) == 2500, "target_range(47) == 2500SkillTable")
_ck(tbl.target_range(91) == 1800, "target_range(91) == 1800SkillTable")
_ck(tbl.target_range(31) == 170, "MELEE_ATTACK 31 -> MELEE_SKILL_TARGET_RANGE 170: %d" % tbl.target_range(31))
_ck(tbl.target_range(5) == 170, "CHARGE_ATTACK 5 -> MELEE_SKILL_TARGET_RANGE 170: %d" % tbl.target_range(5))
# 预约趋近要在 Actor 碰撞(两 body 各 55cm)挡住前进入射程。
for id in tbl._by_id.keys():
var r := tbl.target_range(int(id))
_ck(r == 0 or r - 10 > 110, "skill %d range %d is reachable past actor collision" % [id, r])
# 非扇形/圆形技能:fly_shape == SINGLE(0),无 target_range
_ck(st.fly_shape(1) == 0, "fly_shape(1) == SINGLE(0)samyeon 非范围技)")
_ck(st.target_count(1, 10) == 0, "target_count(1) == 0(无 TargetCountFormula")
@@ -327,6 +358,37 @@ func _run() -> void:
"mobile skill action assigns to first free quickbar slot")
sk.close()
# 射程外(__ProcessEnemySkillTargetRange):不 cast、预约;蓄力技仅首次预约前发 use_skill(id, 0)
if st.has(5) and st.is_charge_skill(5):
var np := FakeRangeNetPlay.new()
get_root().add_child(np)
fc.skills.append({"id": 5, "level": 1, "master": 0})
var qb3: Node = Quickbar.new()
qb3.net_play = np
get_root().add_child(qb3)
qb3.setup(fc, st, ui, func() -> Node: return pl)
qb3.assign(5, "skill", 5, false)
var uses0: int = fc.skill_uses.size()
var casts0: int = fc.casts.size()
var rejected := []
qb3.skill_rejected.connect(func(sid: int, code: String): rejected.append([sid, code]))
qb3.activate(5)
var range5: int = st.target_range(5) + 200
_ck(fc.casts.size() == casts0 and fc.skill_uses.slice(uses0) == [[5, 0]],
"charge skill out of range -> only use_skill(5, 0), no cast: %s" % [fc.skill_uses.slice(uses0)])
_ck(np.reservations == [[2000, 5, range5]], "out of range -> reserve_use_skill(target, slot, TargetRange+bow*100)")
_ck(rejected.is_empty(), "reservation is silent (no OnCannotUseSkill)")
qb3.activate(5)
_ck(fc.skill_uses.size() == uses0 + 1 and np.reservations.size() == 2,
"already reserved charge skill -> no second use_skill(5, 0)")
_ck(not qb3.activate_reserved(5), "reserved fire still out of range keeps the new reservation")
np.distance_cm = 10.0
_ck(qb3.activate_reserved(5), "reserved fire in range consumes the reservation")
_ck(fc.casts.size() == casts0 + 1 and fc.skill_uses.back() == [5, 2000], "in range -> use_skill(5, target) + one cast")
fc.skills.pop_back()
qb3.free()
np.free()
# 服务器 GC_QUICKSLOT_* 恢复:type 2 技能 / type 1 道具 / type 3 表情 -> 填格
var qb2: Node = Quickbar.new()
get_root().add_child(qb2)
+21
View File
@@ -54,6 +54,27 @@ func run() -> void:
target.free()
game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO})
check(registry.seen.size() == 1, "despawned target not replaced by caster")
game._on_skill_cast_started(1, 0)
game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO})
check(registry.seen.size() == 1, "cast without a target spawns no target effect")
# A following effect whose target dies/despawns mid-life is freed, never left at a stale position.
var victim := Node3D.new()
root.add_child(victim)
var outside := Node3D.new()
var outside_anchor := Anchor.new()
check(not outside_anchor.configure(outside, Vector3.ZERO, true), "target outside the tree is rejected")
outside_anchor.free()
outside.free()
var follower := Node3D.new()
root.add_child(follower)
var follow_anchor := Anchor.new()
follower.add_child(follow_anchor)
check(follow_anchor.configure(victim, Vector3.ZERO, true), "follower configured")
var follower_ref: WeakRef = weakref(follower)
victim.free()
await process_frame
await process_frame
check(follower_ref.get_ref() == null, "follower freed after its target left the tree")
game.free()
effect.free()
await process_frame
+195
View File
@@ -0,0 +1,195 @@
extends RefCounted
## MAP-02 地图内验收的机位夹具(test/playable/forest-viewpoints.<name>.local.json)。
##
## 机位必须由测试环境负责人在实服上确认(服务器全局 cm)。未配置的 flat/slope/dense
## 只能由地形/树木密度自动挑“候选”机位:照样截图和测量,但用例保持 BLOCKED,
## 不能冒充“经确认的机位”。传送点无法离线推导,缺配置即 BLOCKED。
const Config = preload("res://testing/playable_config.gd")
const SCHEMA_VERSION := 1
const KINDS := ["flat", "slope", "dense", "warp"]
const AUTO_KINDS := ["flat", "slope", "dense"]
const STATUSES := ["confirmed", "unconfirmed"]
const MAP_CELL_CM := 25600.0
const DEFAULT_YAW_DEG := 45.0
## 自动候选:点位离地图边缘、彼此之间和最近树干的最小距离(米),以及坡地的可行走上限。
const AUTO_EDGE_MARGIN_M := 12.0
const AUTO_SEPARATION_M := 20.0
const AUTO_TREE_CLEARANCE_M := 3.0
const AUTO_DENSITY_RADIUS_M := 12.0
const AUTO_MAX_SLOPE_RATIO := 0.6
static func load_file(path: String) -> Dictionary:
var result := {"ok": false, "data": {}, "errors": []}
if path.strip_edges().is_empty():
result.ok = true # 没有夹具文件:全部机位走候选/BLOCKED,而不是失败
return result
if not FileAccess.file_exists(path):
result.errors.append("viewpoint file does not exist: %s" % path)
return result
var file := FileAccess.open(path, FileAccess.READ)
var parsed: Variant = JSON.parse_string(file.get_as_text()) if file else null
if not (parsed is Dictionary):
result.errors.append("viewpoint file root must be a JSON object")
return result
result.data = parsed
result.ok = true
return result
## bounds = {"base": Vector2 cm, "size": Vector2i tiles}GameScene._map_bounds 的结果)。
## 返回 {errors, viewpoints, missing_kinds, races}viewpoints 带 local_m(地图本地米)。
static func plan(data: Dictionary, map_key: String, bounds: Dictionary) -> Dictionary:
var errors: Array[String] = []
var viewpoints: Array = []
var races: Array = []
var out := {"errors": errors, "viewpoints": viewpoints, "missing_kinds": [], "races": races}
if not data.is_empty():
if not _is_int(data.get("schema_version", null)) or int(data.schema_version) != SCHEMA_VERSION:
errors.append("viewpoint schema_version must be %d" % SCHEMA_VERSION)
if not (data.get("maps", null) is Dictionary):
errors.append("viewpoint maps must be an object keyed by map_key")
Config._reject_secrets(data, "", errors)
var maps: Dictionary = data.get("maps", {}) if data.get("maps", {}) is Dictionary else {}
var entry: Variant = maps.get(map_key, {})
if not (entry is Dictionary):
errors.append("maps.%s must be an object" % map_key)
entry = {}
var seen := {}
var listed: Variant = entry.get("viewpoints", [])
if not (listed is Array):
errors.append("maps.%s.viewpoints must be an array" % map_key)
listed = []
for i in listed.size():
var where := "maps.%s.viewpoints[%d]" % [map_key, i]
var item: Variant = listed[i]
if not (item is Dictionary):
errors.append("%s must be an object" % where)
continue
var id := String(item.get("id", "")).strip_edges()
if id.is_empty() or not id.is_valid_filename() or " " in id:
errors.append("%s.id must be a non-empty file-safe name" % where)
elif seen.has(id):
errors.append("%s.id duplicates %s" % [where, id])
seen[id] = true
var kind := String(item.get("kind", ""))
if not (kind in KINDS):
errors.append("%s.kind must be one of %s" % [where, KINDS])
var status := String(item.get("status", ""))
var confirmed_by := String(item.get("confirmed_by", "")).strip_edges()
if not (status in STATUSES):
errors.append("%s.status must be confirmed or unconfirmed" % where)
elif status == "confirmed" and confirmed_by.is_empty():
errors.append("%s is confirmed but confirmed_by (role) is empty" % where)
elif status == "unconfirmed" and not confirmed_by.is_empty():
errors.append("%s is unconfirmed but carries confirmed_by" % where)
var cm: Variant = item.get("server_cm", null)
var server_cm := Vector2(-1, -1)
if not (cm is Array) or cm.size() != 2 or not _is_int(cm[0]) or not _is_int(cm[1]) \
or int(cm[0]) < 0 or int(cm[1]) < 0:
errors.append("%s.server_cm must be [x,y] non-negative integer centimeters" % where)
else:
server_cm = Vector2(int(cm[0]), int(cm[1]))
if not contains_server_cm(bounds, server_cm):
errors.append("%s.server_cm %s is outside %s" % [where, cm, map_key])
var yaw: Variant = item.get("camera_yaw_deg", DEFAULT_YAW_DEG)
if not (yaw is float or yaw is int):
errors.append("%s.camera_yaw_deg must be a number" % where)
yaw = DEFAULT_YAW_DEG
viewpoints.append({"id": id, "kind": kind, "status": status, "source": "config",
"confirmed_by": confirmed_by, "camera_yaw_deg": float(yaw), "server_cm": server_cm,
"local_m": to_local_m(bounds, server_cm)})
var race_list: Variant = entry.get("races", [])
if not (race_list is Array):
errors.append("maps.%s.races must be an array of mob vnums" % map_key)
else:
for value in race_list:
if not _is_int(value) or int(value) <= 0:
errors.append("maps.%s.races must contain positive integers" % map_key)
break
races.append(int(value))
for kind in KINDS:
if not viewpoints.any(func(v: Dictionary) -> bool: return v.kind == kind):
out.missing_kinds.append(kind)
return out
static func contains_server_cm(bounds: Dictionary, server_cm: Vector2) -> bool:
if bounds.is_empty():
return false
var base: Vector2 = bounds.base
var size: Vector2i = bounds.size
return server_cm.x >= base.x and server_cm.y >= base.y \
and server_cm.x < base.x + size.x * MAP_CELL_CM and server_cm.y < base.y + size.y * MAP_CELL_CM
## 服务器全局 cm -> Metin2World 地图本地米(与 MapCoord.to_world 一致:+Z 朝南)。
static func to_local_m(bounds: Dictionary, server_cm: Vector2) -> Vector2:
var base: Vector2 = bounds.get("base", Vector2.ZERO)
return (server_cm - base) * 0.01
static func to_server_cm(bounds: Dictionary, local_m: Vector2) -> Vector2:
var base: Vector2 = bounds.get("base", Vector2.ZERO)
return Vector2(roundf(local_m.x * 100.0 + base.x), roundf(local_m.y * 100.0 + base.y))
## samples: [{p: Vector2 本地米, range_m: 足迹内地形高差, span_m: 足迹宽度, blocked: bool}]
## trees: 树干本地米坐标。只挑 kinds 里请求的种类;挑不出来的种类不返回(调用方记 BLOCKED)。
static func pick_candidates(samples: Array, trees: PackedVector2Array, size_m: Vector2, kinds: Array) -> Array:
var usable: Array = []
for s: Dictionary in samples:
var p: Vector2 = s.p
if bool(s.blocked) or p.x < AUTO_EDGE_MARGIN_M or p.y < AUTO_EDGE_MARGIN_M \
or p.x > size_m.x - AUTO_EDGE_MARGIN_M or p.y > size_m.y - AUTO_EDGE_MARGIN_M:
continue
var nearest := INF
var density := 0
for t in trees:
var d := t.distance_to(p)
nearest = minf(nearest, d)
if d <= AUTO_DENSITY_RADIUS_M:
density += 1
if nearest < AUTO_TREE_CLEARANCE_M:
continue
var slope := float(s.range_m) / maxf(float(s.span_m), 0.001)
usable.append({"p": p, "range_m": float(s.range_m), "slope_ratio": slope, "density": density})
var chosen: Array = []
var centre := size_m * 0.5
for kind in ["flat", "dense", "slope"]:
if not (kind in kinds):
continue
var best: Dictionary = {}
for u: Dictionary in usable:
if chosen.any(func(c: Dictionary) -> bool: return (c.p as Vector2).distance_to(u.p) < AUTO_SEPARATION_M):
continue
if best.is_empty() or _better(kind, u, best, centre):
best = u
if best.is_empty():
continue
if kind == "slope" and float(best.slope_ratio) <= 0.0:
continue
if kind == "dense" and int(best.density) == 0:
continue
var c := best.duplicate()
c["kind"] = kind
chosen.append(c)
return chosen
static func _better(kind: String, a: Dictionary, b: Dictionary, centre: Vector2) -> bool:
match kind:
"flat":
if not is_equal_approx(float(a.range_m), float(b.range_m)):
return float(a.range_m) < float(b.range_m)
return (a.p as Vector2).distance_to(centre) < (b.p as Vector2).distance_to(centre)
"slope":
var a_ok := float(a.slope_ratio) <= AUTO_MAX_SLOPE_RATIO
var b_ok := float(b.slope_ratio) <= AUTO_MAX_SLOPE_RATIO
if a_ok != b_ok:
return a_ok
return float(a.slope_ratio) > float(b.slope_ratio) if a_ok else float(a.slope_ratio) < float(b.slope_ratio)
"dense":
if int(a.density) != int(b.density):
return int(a.density) > int(b.density)
return float(a.range_m) < float(b.range_m)
return false
static func _is_int(value: Variant) -> bool:
return value is int or (value is float and is_equal_approx(value, roundf(value)))
+393 -32
View File
@@ -12,6 +12,17 @@ const REQUIRED_KEYS := [
"waypoints_cm", "allowed_mob_vnums", "allowed_drop_vnums", "skill_cases",
"resolution", "loops", "timeout_seconds",
]
const SERVER_KEYS := ["server_index", "channel", "auth_host", "auth_port", "game_host", "game_port"]
const SUITES := ["playable", "full", "soak"]
const MAP_CELL_CM := 25600.0
const GOLD_VNUM := 1
## 技能证据分两类:只有接收端分发(M2Client 信号)才算服务端结果。
## 本地施法开始、特效节点生成只能作为补充证据,单独出现不能让用例 PASS。
const SERVER_EVIDENCE := ["damage", "target_dead", "server_motion", "effect_cue",
"fly_cue", "affect_added", "sp_changed"]
const LOCAL_EVIDENCE := ["cast_started", "fx_spawned", "fx_finished"]
const SKILL_TARGETS := ["enemy", "self", "ground"]
static func load_file(path: String) -> Dictionary:
var result := {"ok": false, "config": {}, "errors": []}
@@ -41,7 +52,7 @@ static func validate(config: Dictionary) -> Dictionary:
for key in REQUIRED_KEYS:
if not config.has(key):
errors.append("missing required key: %s" % key)
if int(config.get("schema_version", -1)) != SCHEMA_VERSION:
if not _is_int(config.get("schema_version", null)) or int(config.get("schema_version", -1)) != SCHEMA_VERSION:
errors.append("schema_version must be %d" % SCHEMA_VERSION)
if String(config.get("scenario_id", "")).strip_edges().is_empty():
errors.append("scenario_id must not be empty")
@@ -52,21 +63,35 @@ static func validate(config: Dictionary) -> Dictionary:
if not (server is Dictionary):
errors.append("server must be an object selected from serverinfo")
else:
for key in ["server_index", "channel_index"]:
if not server.has(key) or int(server[key]) < 0:
errors.append("server.%s must be a non-negative integer" % key)
if not config.has("character_slot") or int(config.get("character_slot", -1)) < 0:
for key in SERVER_KEYS:
if not server.has(key):
errors.append("server.%s is missing; copy the address selected from serverinfo" % key)
if not _is_int(server.get("server_index", null)) or int(server.get("server_index", -1)) < 0:
errors.append("server.server_index must be a non-negative integer")
if not _is_int(server.get("channel", null)) or int(server.get("channel", 0)) < 1:
errors.append("server.channel must be a channel number (>= 1)")
for key in ["auth_host", "game_host"]:
if String(server.get(key, "")).strip_edges().is_empty():
errors.append("server.%s must not be empty" % key)
for key in ["auth_port", "game_port"]:
if not _is_int(server.get(key, null)) or int(server.get(key, 0)) <= 0 or int(server.get(key, 0)) > 65535:
errors.append("server.%s must be 1..65535" % key)
if not _is_int(config.get("character_slot", null)) or int(config.get("character_slot", -1)) < 0:
errors.append("character_slot must be a non-negative integer")
if String(config.get("map_key", "")).strip_edges().is_empty():
var map_key := String(config.get("map_key", "")).strip_edges()
if map_key.is_empty():
errors.append("map_key is empty; resolve it from the server/map fixture")
elif map_key.begins_with("/") or ".." in map_key.split("/") or "\\" in map_key:
errors.append("map_key must be a relative asset directory such as outdoortrent/metin2_map_trent")
var waypoints: Variant = config.get("waypoints_cm", null)
if not (waypoints is Array) or waypoints.is_empty():
errors.append("waypoints_cm must contain at least one [x,y] centimeter point")
else:
for i in waypoints.size():
var point: Variant = waypoints[i]
if not (point is Array) or point.size() != 2 or not _is_number(point[0]) or not _is_number(point[1]):
errors.append("waypoints_cm[%d] must be [x,y] numbers" % i)
if not (point is Array) or point.size() != 2 or not _is_int(point[0]) or not _is_int(point[1]) \
or int(point[0]) < 0 or int(point[1]) < 0:
errors.append("waypoints_cm[%d] must be [x,y] non-negative integer centimeters" % i)
var mobs: Variant = config.get("allowed_mob_vnums", null)
if not (mobs is Array) or mobs.is_empty():
errors.append("allowed_mob_vnums must contain confirmed server mob vnums")
@@ -77,10 +102,347 @@ static func validate(config: Dictionary) -> Dictionary:
errors.append("allowed_drop_vnums must contain a deterministic pickup fixture")
else:
_validate_positive_int_array(drops, "allowed_drop_vnums", errors)
var skills: Variant = config.get("skill_cases", null)
for value in drops:
if _is_int(value) and int(value) == GOLD_VNUM:
errors.append("allowed_drop_vnums must not contain gold (vnum 1); money is a separate points case")
_validate_skill_cases(config.get("skill_cases", null), errors)
var resolution: Variant = config.get("resolution", null)
if not (resolution is Array) or resolution.size() != 2 or not _is_int(resolution[0]) or not _is_int(resolution[1]) \
or int(resolution[0]) <= 0 or int(resolution[1]) <= 0:
errors.append("resolution must be [width,height]")
if not _is_int(config.get("loops", null)) or int(config.get("loops", 0)) < 1:
errors.append("loops must be at least 1")
if not _is_int(config.get("timeout_seconds", null)) or int(config.get("timeout_seconds", 0)) < 1:
errors.append("timeout_seconds must be positive")
# This config is deliberately data-only. Reject the common accidental secret paths anywhere in the tree.
_reject_secrets(config, "", errors)
return {"ok": errors.is_empty(), "errors": errors}
## test/playable/skill-cases.json:每职业 × {单体, 范围, 自身增益, 飞行效果} 的实际技能矩阵。
## 条目只能是 unconfirmed(缺夹具 -> BLOCKED)、confirmed(环境负责人确认的已学技能)或
## not_applicable(附清单确认记录)。脚本从不补技能 ID;confirmed 的 skill_id 必须属于该职业
## 且不是被动技,每技能至少成功施放 SKILL_MATRIX_REPEATS 次。
const SKILL_MATRIX_TYPES := ["single_target", "area", "self_buff", "flying"]
const SKILL_MATRIX_JOBS := ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"]
const SKILL_MATRIX_REPEATS := 10
static func skill_matrix_cases(matrix: Dictionary, job: String, skill_table: RefCounted = null) -> Dictionary:
var errors: Array[String] = []
var blocked: Array[String] = []
var not_applicable: Array[String] = []
var cases: Array = []
var result := {"errors": errors, "blocked": blocked, "not_applicable": not_applicable, "cases": cases}
if not _is_int(matrix.get("schema_version", null)) or int(matrix.get("schema_version", -1)) != SCHEMA_VERSION:
errors.append("skill matrix schema_version must be %d" % SCHEMA_VERSION)
var jobs: Variant = matrix.get("jobs", null)
if not (jobs is Dictionary):
errors.append("skill matrix jobs must be an object")
return result
for name in jobs:
if not (String(name) in SKILL_MATRIX_JOBS):
errors.append("skill matrix has unknown job: %s" % name)
if not (job in SKILL_MATRIX_JOBS) or not (jobs.get(job, null) is Dictionary):
errors.append("skill matrix has no entry for job %s" % job)
return result
var row: Dictionary = jobs[job]
for type in row:
if not (String(type) in SKILL_MATRIX_TYPES):
errors.append("%s has unknown skill type: %s" % [job, type])
for type in SKILL_MATRIX_TYPES:
var where := "%s/%s" % [job, type]
var entry: Variant = row.get(type, null)
if not (entry is Dictionary):
errors.append("%s is missing" % where)
continue
match String(entry.get("status", "")):
"unconfirmed":
if entry.has("skill_id"):
errors.append("%s is unconfirmed but carries a skill_id" % where)
blocked.append(where)
"not_applicable":
if String(entry.get("checklist_ref", "")).strip_edges().is_empty():
errors.append("%s not_applicable needs checklist_ref" % where)
not_applicable.append(where)
"confirmed":
var skill_id := int(entry.skill_id) if _is_int(entry.get("skill_id", null)) else 0
if skill_table != null and skill_id > 0:
if String(skill_table.category_of(skill_id)) != job:
errors.append("%s skill_id %d does not belong to %s" % [where, skill_id, job])
elif skill_table.is_passive(skill_id):
errors.append("%s skill_id %d is passive" % [where, skill_id])
if entry.has("repeats") and _is_int(entry.repeats) and int(entry.repeats) < SKILL_MATRIX_REPEATS:
errors.append("%s repeats must be at least %d" % [where, SKILL_MATRIX_REPEATS])
cases.append({"case_id": "%s-%s" % [job.to_lower(), type.replace("_", "-")],
"skill_id": entry.get("skill_id", null), "target": entry.get("target", "enemy"),
"repeats": entry.get("repeats", SKILL_MATRIX_REPEATS),
"required_evidence": entry.get("required_evidence", null)})
_:
errors.append("%s status must be unconfirmed, confirmed or not_applicable" % where)
if not cases.is_empty():
_validate_skill_cases(cases, errors)
elif blocked.is_empty():
errors.append("%s has no confirmed skill case" % job)
return result
## 必测用例清单由配置推导,客户端与父运行器共用同一份结果,
## 运行器在启动前落盘,校验器据此拒绝“少报一个用例”的 PASS。
static func required_cases(config: Dictionary, suite: String) -> Array[String]:
var out: Array[String] = ["CONFIG-01", "NET-LOGIN-01", "NET-SELECT-01", "NET-WORLD-01"]
var waypoints: Variant = config.get("waypoints_cm", [])
if waypoints is Array:
for i in waypoints.size():
out.append("NET-MOVE-%02d" % (i + 1))
out.append_array(["NET-MOVE-CONFIRM-01", "NET-TARGET-01", "NET-ATTACK-01", "NET-DROP-01", "NET-PICKUP-01"])
if suite == "full":
var skills: Variant = config.get("skill_cases", [])
if skills is Array:
for item in skills:
if item is Dictionary:
out.append(skill_case_id(item))
if suite == "soak":
# STB-MEMORY-01 is a runner case (external RSS sampler); exits are counted by the runner.
out.append_array(SOAK_CASES)
return out
## STB-01 §9.2 soak block. The minimums are the release acceptance numbers, not
## defaults: a shorter or smaller soak is rejected instead of silently reported as PASS.
## Warp routes and real transport faults need environment-owner confirmation; the
## unconfirmed status is valid config but the matching case stays BLOCKED.
const SOAK_CASES := ["STB-DURATION-01", "STB-FRAMES-01", "STB-RECONNECT-01", "STB-WARP-01",
"STB-DISCONNECT-01", "STB-RESIZE-01"]
const SOAK_MIN_DURATION_S := 7200
const SOAK_MIN_REST_S := 30
const SOAK_MIN_ROUNDS := 10
const SOAK_MIN_RECONNECTS := 10
const SOAK_MIN_EXITS := 10
const SOAK_MIN_WARPS := 20
const SOAK_RESOLUTION_COUNT := 3
const SOAK_MIN_SWITCHES := 10
const SOAK_TIMEOUT_MARGIN_S := 900
const SOAK_MAX_UNREACHABLE_S := 20
const SOAK_FAULT_TYPES := ["close", "unreachable"]
## soak_client=false validates a soak block seen by another suite (the playable exit runs
## sharing the scenario): everything except the client timeout margin, which only the
## 2 h soak client needs.
static func validate_soak(config: Dictionary, soak_client := true) -> Dictionary:
var errors: Array[String] = []
var soak: Variant = config.get("soak", null)
if not (soak is Dictionary):
errors.append("soak must be an object for the soak suite")
return {"ok": false, "errors": errors}
var minimums := {"duration_seconds": SOAK_MIN_DURATION_S, "rest_seconds": SOAK_MIN_REST_S, "warmup_rounds": 0,
"min_rounds": SOAK_MIN_ROUNDS, "reconnects": SOAK_MIN_RECONNECTS, "exits": SOAK_MIN_EXITS}
for key in minimums:
if not _is_int(soak.get(key, null)) or int(soak.get(key, -1)) < int(minimums[key]):
errors.append("soak.%s must be an integer >= %d" % [key, minimums[key]])
var duration := int(soak.get("duration_seconds", 0)) if _is_int(soak.get("duration_seconds", null)) else 0
if soak_client and _is_int(config.get("timeout_seconds", null)) and int(config.timeout_seconds) < duration + SOAK_TIMEOUT_MARGIN_S:
errors.append("timeout_seconds must be at least soak.duration_seconds + %d" % SOAK_TIMEOUT_MARGIN_S)
var resolutions: Variant = soak.get("resolutions", null)
if not (resolutions is Dictionary):
errors.append("soak.resolutions must be an object")
else:
var sizes: Variant = resolutions.get("sizes", null)
var seen := {}
if not (sizes is Array) or sizes.size() != SOAK_RESOLUTION_COUNT:
errors.append("soak.resolutions.sizes must list exactly %d logical sizes" % SOAK_RESOLUTION_COUNT)
else:
for i in sizes.size():
var size: Variant = sizes[i]
if not (size is Array) or size.size() != 2 or not _is_int(size[0]) or not _is_int(size[1]) \
or int(size[0]) <= 0 or int(size[1]) <= 0:
errors.append("soak.resolutions.sizes[%d] must be [width,height] logical points" % i)
continue
var key := "%dx%d" % [int(size[0]), int(size[1])]
if seen.has(key):
errors.append("soak.resolutions.sizes[%d] duplicates %s" % [i, key])
seen[key] = true
if not _is_int(resolutions.get("switches_per_size", null)) or int(resolutions.get("switches_per_size", 0)) < SOAK_MIN_SWITCHES:
errors.append("soak.resolutions.switches_per_size must be >= %d" % SOAK_MIN_SWITCHES)
var warp: Variant = soak.get("warp", null)
if not (warp is Dictionary):
errors.append("soak.warp must be an object with status unconfirmed|confirmed")
else:
match String(warp.get("status", "")):
"unconfirmed":
pass
"confirmed":
if not _is_int(warp.get("required", null)) or int(warp.get("required", 0)) < SOAK_MIN_WARPS or int(warp.get("required", 0)) % 2 != 0:
errors.append("soak.warp.required must be an even integer >= %d (out-and-back legs)" % SOAK_MIN_WARPS)
for key in ["portal_cm", "return_portal_cm"]:
if not _is_cm_point(warp.get(key, null)):
errors.append("soak.warp.%s must be [x,y] non-negative integer centimeters" % key)
var destination := String(warp.get("destination_map_key", "")).strip_edges()
if destination.is_empty() or destination.begins_with("/") or ".." in destination.split("/") or "\\" in destination:
errors.append("soak.warp.destination_map_key must be a relative asset directory")
elif destination == String(config.get("map_key", "")):
errors.append("soak.warp.destination_map_key must differ from map_key")
if not (warp.get("cross_server", null) is bool):
errors.append("soak.warp.cross_server must be true or false as verified on the test server")
_:
errors.append("soak.warp.status must be unconfirmed or confirmed")
var faults: Variant = soak.get("faults", null)
if not (faults is Dictionary):
errors.append("soak.faults must be an object with status unconfirmed|confirmed")
else:
match String(faults.get("status", "")):
"unconfirmed":
pass
"confirmed":
# Only a test-connection proxy on 127.0.0.1; never global routes or firewall rules.
if String(faults.get("mode", "")) != "local_proxy":
errors.append("soak.faults.mode must be local_proxy")
if not _is_int(faults.get("per_type", null)) or int(faults.get("per_type", 0)) < 1:
errors.append("soak.faults.per_type must be a positive integer")
var seconds: Variant = faults.get("unreachable_seconds", null)
if not _is_int(seconds) or int(seconds) < 1 or int(seconds) > SOAK_MAX_UNREACHABLE_S:
errors.append("soak.faults.unreachable_seconds must be 1..%d" % SOAK_MAX_UNREACHABLE_S)
_check_fault_upstream(config, faults.get("upstream", null), errors)
_:
errors.append("soak.faults.status must be unconfirmed or confirmed")
_reject_secrets(soak, "soak.", errors)
return {"ok": errors.is_empty(), "errors": errors}
## The proxy (script/playable_fault_proxy.mjs) listens on server.* which must be loopback,
## and forwards to upstream (the real test server). It only carries this client's connection.
static func _check_fault_upstream(config: Dictionary, upstream: Variant, errors: Array[String]) -> void:
var server: Variant = config.get("server", {})
if not (server is Dictionary):
return
for key in ["auth_host", "game_host"]:
if String(server.get(key, "")) != "127.0.0.1":
errors.append("soak.faults local_proxy needs server.%s = 127.0.0.1 (the proxy listen address)" % key)
if not (upstream is Dictionary):
errors.append("soak.faults.upstream must give auth_host/auth_port/game_host/game_port of the real test server")
return
for role in ["auth", "game"]:
var host := String(upstream.get(role + "_host", "")).strip_edges()
var port: Variant = upstream.get(role + "_port", null)
if host.is_empty() or " " in host:
errors.append("soak.faults.upstream.%s_host is missing" % role)
if not _is_int(port) or int(port) < 1 or int(port) > 65535:
errors.append("soak.faults.upstream.%s_port must be 1..65535" % role)
elif host == String(server.get(role + "_host", "")) and int(port) == int(server.get(role + "_port", -1)):
errors.append("soak.faults.upstream.%s points at the proxy itself" % role)
static func _is_cm_point(point: Variant) -> bool:
return point is Array and point.size() == 2 and _is_int(point[0]) and _is_int(point[1]) \
and int(point[0]) >= 0 and int(point[1]) >= 0
static func skill_case_id(item: Dictionary) -> String:
return "CBT-%s" % String(item.get("case_id", "")).strip_edges()
## Resolve the configured address through the same ServerInfo object the
## client uses. The config must copy the resolved address verbatim; any drift
## between the precheck target and the client connect target is an error.
static func resolve_server(config: Dictionary, serverinfo: RefCounted) -> Dictionary:
var errors: Array[String] = []
var server: Dictionary = config.get("server", {}) if config.get("server", null) is Dictionary else {}
var index := int(server.get("server_index", -1))
var channel := int(server.get("channel", 0))
var entry: Dictionary = serverinfo.server(index) if serverinfo != null else {}
if entry.is_empty():
errors.append("server_index %d is not present in serverinfo" % index)
return {"ok": false, "address": {}, "errors": errors}
var channels: Array = entry.get("channels", [])
if not (channel in channels):
errors.append("channel %d is not listed by serverinfo server %d" % [channel, index])
var address: Dictionary = serverinfo.address(index, channel)
for key in ["auth_host", "game_host"]:
if String(server.get(key, "")) != String(address.get(key, "")):
errors.append("server.%s differs from serverinfo" % key)
for key in ["auth_port", "game_port"]:
if int(server.get(key, -1)) != int(address.get(key, -2)):
errors.append("server.%s differs from serverinfo" % key)
return {"ok": errors.is_empty(), "errors": errors, "address": {
"server_index": index, "channel": channel,
"auth_host": String(address.get("auth_host", "")), "auth_port": int(address.get("auth_port", 0)),
"game_host": String(address.get("game_host", "")), "game_port": int(address.get("game_port", 0)),
}}
## Resource preconditions: the map must exist in the asset root and every
## waypoint must fall inside the same Setting.txt rectangle GameScene uses to
## resolve a map from the server position.
static func check_resources(config: Dictionary, assets_root: String) -> Dictionary:
var errors: Array[String] = []
var root := assets_root.trim_suffix("/")
var map_key := String(config.get("map_key", ""))
var setting := root.path_join(map_key).path_join("Setting.txt")
if root.is_empty() or not DirAccess.dir_exists_absolute(root):
errors.append("asset root does not exist")
return {"ok": false, "errors": errors, "bounds": {}}
if not FileAccess.file_exists(setting):
errors.append("map_key has no Setting.txt in the asset root: %s" % map_key)
return {"ok": false, "errors": errors, "bounds": {}}
var bounds := map_bounds(setting)
if bounds.is_empty():
errors.append("map Setting.txt lacks BasePosition/MapSize: %s" % map_key)
return {"ok": false, "errors": errors, "bounds": {}}
var base: Vector2 = bounds.base
var size: Vector2i = bounds.size
var waypoints: Array = config.get("waypoints_cm", [])
for i in waypoints.size():
var point: Array = waypoints[i]
var x := float(point[0])
var y := float(point[1])
if x < base.x or y < base.y or x >= base.x + size.x * MAP_CELL_CM or y >= base.y + size.y * MAP_CELL_CM:
errors.append("waypoints_cm[%d] is outside %s bounds" % [i, map_key])
return {"ok": errors.is_empty(), "errors": errors,
"bounds": {"base_cm": [base.x, base.y], "size_cells": [size.x, size.y]}}
static func map_bounds(setting_path: String) -> Dictionary:
var f := FileAccess.open(setting_path, FileAccess.READ)
if f == null:
return {}
var base := Vector2.ZERO
var size := Vector2i.ZERO
var have_base := false
var have_size := false
while not f.eof_reached():
var fields := f.get_line().replace("\t", " ").strip_edges().split(" ", false)
if fields.size() < 3:
continue
match String(fields[0]).to_lower():
"baseposition":
base = Vector2(float(fields[1]), float(fields[2]))
have_base = true
"mapsize":
size = Vector2i(int(fields[1]), int(fields[2]))
have_size = size.x > 0 and size.y > 0
if not have_base or not have_size:
return {}
return {"base": base, "size": size}
static func _reject_secrets(value: Variant, prefix: String, errors: Array[String]) -> void:
if value is Dictionary:
for key in value:
var name := String(key).to_lower()
for forbidden in ["account", "username", "user_id", "login", "password", "passwd", "token", "secret"]:
if name == forbidden or name.ends_with("_" + forbidden):
errors.append("credentials must not be stored in config: %s%s" % [prefix, key])
break
_reject_secrets(value[key], "%s%s." % [prefix, key], errors)
elif value is Array:
for i in value.size():
_reject_secrets(value[i], "%s%d." % [prefix, i], errors)
static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void:
for i in values.size():
if not _is_int(values[i]) or int(values[i]) <= 0:
errors.append("%s[%d] must be a positive integer" % [name, i])
## JSON numbers arrive as float; accept only integral values.
static func _is_int(value: Variant) -> bool:
if value is int:
return true
return value is float and is_finite(value) and float(value) == floorf(value)
static func _validate_skill_cases(skills: Variant, errors: Array[String]) -> void:
if not (skills is Array) or skills.is_empty():
errors.append("skill_cases must contain at least one confirmed skill case")
else:
var seen := {}
for i in skills.size():
if not (skills[i] is Dictionary):
errors.append("skill_cases[%d] must be an object" % i)
@@ -89,29 +451,28 @@ static func validate(config: Dictionary) -> Dictionary:
for key in ["case_id", "skill_id", "required_evidence"]:
if not item.has(key):
errors.append("skill_cases[%d] missing %s" % [i, key])
if String(item.get("case_id", "")).strip_edges().is_empty():
var case_id := String(item.get("case_id", "")).strip_edges()
if case_id.is_empty():
errors.append("skill_cases[%d].case_id is empty" % i)
if int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255:
elif seen.has(case_id):
errors.append("skill_cases[%d].case_id is duplicated: %s" % [i, case_id])
seen[case_id] = true
if not _is_int(item.get("skill_id", null)) or int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255:
errors.append("skill_cases[%d].skill_id must be 1..254" % i)
if not (item.get("required_evidence", null) is Array) or item.required_evidence.is_empty():
if item.has("target") and not (String(item.target) in SKILL_TARGETS):
errors.append("skill_cases[%d].target must be one of %s" % [i, SKILL_TARGETS])
if item.has("repeats") and (not _is_int(item.repeats) or int(item.repeats) < 1):
errors.append("skill_cases[%d].repeats must be a positive integer" % i)
var evidence: Variant = item.get("required_evidence", null)
if not (evidence is Array) or evidence.is_empty():
errors.append("skill_cases[%d].required_evidence must be non-empty" % i)
var resolution: Variant = config.get("resolution", null)
if not (resolution is Array) or resolution.size() != 2 or int(resolution[0]) <= 0 or int(resolution[1]) <= 0:
errors.append("resolution must be [width,height]")
if int(config.get("loops", 0)) < 1:
errors.append("loops must be at least 1")
if int(config.get("timeout_seconds", 0)) < 1:
errors.append("timeout_seconds must be positive")
# This config is deliberately data-only. Reject the common accidental secret paths.
for forbidden in ["account", "username", "password", "token", "secret"]:
if config.has(forbidden):
errors.append("credentials must not be stored in config: %s" % forbidden)
return {"ok": errors.is_empty(), "errors": errors}
static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void:
for i in values.size():
if not _is_number(values[i]) or int(values[i]) <= 0 or float(values[i]) != float(int(values[i])):
errors.append("%s[%d] must be a positive integer" % [name, i])
static func _is_number(value: Variant) -> bool:
return value is int or value is float
continue
var has_server := false
for kind in evidence:
var name := String(kind)
if name in SERVER_EVIDENCE:
has_server = true
elif not (name in LOCAL_EVIDENCE):
errors.append("skill_cases[%d].required_evidence has unknown kind: %s" % [i, name])
if not has_server:
errors.append("skill_cases[%d].required_evidence needs at least one server-side kind %s" % [i, SERVER_EVIDENCE])
File diff suppressed because it is too large Load Diff
+292
View File
@@ -0,0 +1,292 @@
class_name PlayableMetrics
extends RefCounted
## STB-01 §9.1 帧间隔与长帧指标(纯数据,时钟可注入)。
##
## - frame() 每帧调用一次,记录与上一帧的 monotonic 间隔;首个调用只起表。
## - 所有存储都有固定上限:当前窗口的样本在 capacity 大小的环形缓冲里,
## 已关闭窗口 / 长帧事件 / 标记 / 分段表各有上限,超出只计丢弃数,
## 测试自身不会因为长跑而无限增长内存。
## - 窗口按注入时钟(墙钟期限)关闭,或在分段标签变化时关闭;不按帧数。
## 窗口样本超过 capacity 时分位数只来自最近 capacity 个样本并标 truncated
## max 与 >50ms / >100ms 计数始终精确。
## - 分段标签只取 map / phase / actor_count / gpu_skinactor_count 请用 actor_bucket() 分桶。
## - 默认不逐帧打印,只保留长帧事件(>50ms),附带本帧和上一帧内的标记用于归因。
## - GPU 内存拿不到时写 null + "unavailable",从不写 0。
const TAG_KEYS := ["map", "phase", "actor_count", "gpu_skin"]
const LONG_MS := 50.0
const SEVERE_MS := 100.0
## 9.3 标记:同步模型 build、动作 reload、GR2 解析、材质初次使用、实体批量生成、缓存命中等。
const MARKER_DETAIL_KEYS := ["race", "count", "ms", "state", "map", "hits", "misses", "size", "round", "kind"]
const MAX_PENDING_MARKERS := 16
const MAX_MARKER_KIND_LENGTH := 32
var capacity := 4096
var window_us := 60_000_000
var max_windows := 512
var max_long_frames := 512
var max_markers := 1024
var max_segments := 64
var print_frames := false
var clock_us: Callable = func() -> int: return Time.get_ticks_usec()
var _ring := PackedFloat32Array()
var _ring_head := 0
var _ring_len := 0
var _window_count := 0
var _window_over_long := 0
var _window_over_severe := 0
var _window_max := 0.0
var _window_start_us := -1
var _origin_us := -1
var _last_frame_us := -1
var _tags := {}
var _tag_key := ""
var _frames := 0
var _windows: Array = []
var _windows_dropped := 0
var _segments := {}
var _unsegmented_frames := 0
var _severe_by_phase := {}
var _long_unattributed_by_phase := {}
var _long_frames: Array = []
var _long_frames_dropped := 0
var _markers: Array = []
var _markers_dropped := 0
var _pending_markers: Array[String] = []
var _previous_markers: Array[String] = []
func _init() -> void:
_ring.resize(capacity)
func configure(options: Dictionary) -> void:
capacity = maxi(1, int(options.get("capacity", capacity)))
window_us = maxi(1, int(float(options.get("window_seconds", window_us / 1_000_000.0)) * 1_000_000.0))
max_windows = maxi(1, int(options.get("max_windows", max_windows)))
max_long_frames = maxi(1, int(options.get("max_long_frames", max_long_frames)))
max_markers = maxi(1, int(options.get("max_markers", max_markers)))
max_segments = maxi(1, int(options.get("max_segments", max_segments)))
print_frames = bool(options.get("print_frames", false))
_ring.resize(capacity)
_ring_head = 0
_ring_len = 0
## Switches the segment. A different tag set closes the current window first.
func set_tags(tags: Dictionary) -> void:
var normalized := {}
for key in TAG_KEYS:
if tags.has(key):
normalized[key] = String(str(tags[key])).left(80)
var key := JSON.stringify(normalized)
if key == _tag_key:
return
if _window_count > 0:
close_window()
_tags = normalized
_tag_key = key
func tags() -> Dictionary:
return _tags.duplicate()
## Call once per rendered frame. The first call only arms the clock.
func frame(now_us := -1) -> void:
var now := now_us if now_us >= 0 else int(clock_us.call())
if _last_frame_us >= 0:
add_interval_us(now - _last_frame_us, now)
else:
_arm(now)
_last_frame_us = now
## Forget the previous frame time (e.g. before a deliberately unmeasured pause).
func reset_frame_clock() -> void:
_last_frame_us = -1
func add_interval_us(interval_us: int, now_us: int) -> void:
if _origin_us < 0:
_arm(now_us - interval_us)
if _window_start_us < 0:
_window_start_us = now_us - interval_us
var ms := float(interval_us) / 1000.0
_ring[_ring_head] = ms
_ring_head = (_ring_head + 1) % capacity
_ring_len = mini(_ring_len + 1, capacity)
_window_count += 1
_frames += 1
_window_max = maxf(_window_max, ms)
var segment := _segment()
if not segment.is_empty():
segment.frames = int(segment.frames) + 1
segment.max_ms = maxf(float(segment.max_ms), ms)
var attribution: Array[String] = []
for kind in _previous_markers + _pending_markers:
if not (kind in attribution):
attribution.append(kind)
_previous_markers = _pending_markers
_pending_markers = []
if ms > LONG_MS:
_window_over_long += 1
if not segment.is_empty():
segment.over_50ms = int(segment.over_50ms) + 1
_push_bounded(_long_frames, {"t_ms": _t_ms(now_us), "ms": ms, "tags": _tags.duplicate(),
"markers": attribution}, max_long_frames, "_long_frames_dropped")
if attribution.is_empty():
var long_phase := String(_tags.get("phase", ""))
if _long_unattributed_by_phase.has(long_phase) or _long_unattributed_by_phase.size() < max_segments:
_long_unattributed_by_phase[long_phase] = int(_long_unattributed_by_phase.get(long_phase, 0)) + 1
if ms > SEVERE_MS:
_window_over_severe += 1
if not segment.is_empty():
segment.over_100ms = int(segment.over_100ms) + 1
var phase := String(_tags.get("phase", ""))
if _severe_by_phase.has(phase) or _severe_by_phase.size() < max_segments:
_severe_by_phase[phase] = int(_severe_by_phase.get(phase, 0)) + 1
if print_frames:
print("FRAME ms=%.3f tags=%s" % [ms, _tag_key])
if now_us - _window_start_us >= window_us:
close_window(now_us)
## Attribution marker (model build, motion reload, GR2 parse, material first use, entity batch, ...).
func mark(kind: String, detail := {}) -> void:
var name := kind.left(MAX_MARKER_KIND_LENGTH)
var clean := {}
for key in detail:
if String(key) in MARKER_DETAIL_KEYS:
var value: Variant = detail[key]
clean[String(key)] = value if (value is int or value is float or value is bool) else String(str(value)).left(80)
var now := int(clock_us.call())
_push_bounded(_markers, {"t_ms": _t_ms(now), "kind": name, "tags": _tags.duplicate(), "detail": clean},
max_markers, "_markers_dropped")
if _pending_markers.size() < MAX_PENDING_MARKERS and not (name in _pending_markers):
_pending_markers.append(name)
func close_window(now_us := -1) -> void:
if _window_count == 0:
return
var end := now_us if now_us >= 0 else maxi(_last_frame_us, _window_start_us)
var sorted := PackedFloat32Array()
sorted.resize(_ring_len)
for i in _ring_len:
sorted[i] = _ring[(_ring_head - _ring_len + i + capacity) % capacity]
sorted.sort()
var window := {"tags": _tags.duplicate(), "start_ms": _t_ms(_window_start_us), "end_ms": _t_ms(end),
"count": _window_count, "sampled": _ring_len, "truncated": _window_count > _ring_len,
"p50_ms": percentile(sorted, 0.50), "p95_ms": percentile(sorted, 0.95), "p99_ms": percentile(sorted, 0.99),
"max_ms": _window_max, "over_50ms": _window_over_long, "over_100ms": _window_over_severe}
_push_bounded(_windows, window, max_windows, "_windows_dropped")
var segment: Dictionary = _segments.get(_tag_key, {})
if not segment.is_empty():
segment.windows = int(segment.windows) + 1
segment.worst_p99_ms = maxf(float(segment.worst_p99_ms), float(window.p99_ms))
_ring_head = 0
_ring_len = 0
_window_count = 0
_window_over_long = 0
_window_over_severe = 0
_window_max = 0.0
_window_start_us = end
func summary() -> Dictionary:
close_window()
var segments: Array = []
for key in _segments:
segments.append(_segments[key].duplicate(true))
return {"schema_version": 1, "capacity": capacity, "window_seconds": window_us / 1_000_000.0,
"thresholds_ms": [LONG_MS, SEVERE_MS], "print_frames": print_frames, "frames": _frames,
"segments": segments, "unsegmented_frames": _unsegmented_frames,
"windows": _windows.duplicate(true), "windows_dropped": _windows_dropped,
"long_frames": _long_frames.duplicate(true), "long_frames_dropped": _long_frames_dropped,
"markers": _markers.duplicate(true), "markers_dropped": _markers_dropped,
"severe_by_phase": _severe_by_phase.duplicate(),
"long_unattributed_by_phase": _long_unattributed_by_phase.duplicate()}
func stored_sample_count() -> int:
return _ring_len
## Cumulative >100ms counts per phase tag; diff two snapshots to judge one repetition.
func severe_by_phase() -> Dictionary:
return _severe_by_phase.duplicate()
## Exact per-phase count of >50ms frames without a marker (not limited by max_long_frames).
func long_unattributed_by_phase() -> Dictionary:
return _long_unattributed_by_phase.duplicate()
## Retained long frames that have no marker in their frame or the one before. They
## need manual attribution (§9.3); the automated gate never explains them away.
func unattributed_long_frames() -> Array:
return _long_frames.filter(func(item: Dictionary) -> bool: return item.markers.is_empty())
## Nearest-rank percentile of an ascending array.
static func percentile(sorted: PackedFloat32Array, q: float) -> float:
if sorted.is_empty():
return 0.0
var index := clampi(int(ceil(q * sorted.size())) - 1, 0, sorted.size() - 1)
return sorted[index]
static func actor_bucket(count: int) -> String:
if count <= 0:
return "0"
for limit in [8, 16, 32, 64]:
if count <= limit:
return "%d-%d" % [limit / 2 + 1 if limit > 8 else 1, limit]
return "65+"
## §9.3: an interaction repeated at least 3 times where 2 of any 3 consecutive
## repetitions contained a >100ms frame. history: interaction -> Array[bool].
static func repeated_severe(history: Dictionary) -> Array[String]:
var out: Array[String] = []
for key in history:
var runs: Array = history[key]
for i in range(0, runs.size() - 2):
var hits := int(bool(runs[i])) + int(bool(runs[i + 1])) + int(bool(runs[i + 2]))
if hits >= 2:
out.append(String(key))
break
return out
## MEMORY_STATIC is Godot's own allocator counter, not process RSS (the runner
## samples RSS externally). GPU memory has no reliable source here.
static func memory_snapshot() -> Dictionary:
var video := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED))
return {"memory_static_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC)) / 1024,
"memory_static_max_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC_MAX)) / 1024,
"gpu_memory_kib": null, "gpu_memory_status": "unavailable",
"godot_video_mem_kib": video / 1024 if video > 0 else null,
"godot_video_mem_note": "Godot RenderingDevice allocation counter; not system GPU memory"}
## Cost of one sample on this machine; compare with the frame budget in reports.
static func measure_overhead(iterations := 20000) -> Dictionary:
var probe := new()
probe.configure({"capacity": 4096, "window_seconds": 3600})
probe.set_tags({"map": "overhead", "phase": "probe", "actor_count": "1-8", "gpu_skin": false})
var started := Time.get_ticks_usec()
for i in iterations:
probe.add_interval_us(16_000 + (i % 7) * 1000, (i + 1) * 16_000)
var elapsed := Time.get_ticks_usec() - started
return {"iterations": iterations, "total_us": elapsed, "per_sample_us": float(elapsed) / maxf(1.0, iterations)}
func _arm(now_us: int) -> void:
if _origin_us < 0:
_origin_us = now_us
if _window_start_us < 0:
_window_start_us = now_us
func _t_ms(us: int) -> float:
return float(us - maxi(_origin_us, 0)) / 1000.0
func _segment() -> Dictionary:
if _segments.has(_tag_key):
return _segments[_tag_key]
if _segments.size() >= max_segments:
_unsegmented_frames += 1
return {}
var row := {"tags": _tags.duplicate(), "frames": 0, "windows": 0, "max_ms": 0.0, "worst_p99_ms": 0.0,
"over_50ms": 0, "over_100ms": 0}
_segments[_tag_key] = row
return row
func _push_bounded(list: Array, item: Dictionary, limit: int, dropped_field: String) -> void:
if list.size() >= limit:
list.pop_front()
set(dropped_field, int(get(dropped_field)) + 1)
list.append(item)
+183 -51
View File
@@ -3,113 +3,245 @@ extends Node
## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入,
## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。
##
## 每条观察都带 source
## "server" —— M2Client 接收端派发(EntityStore 变化 / 收包回调)
## "local" —— 本地 UI/表现层信号(施法开始、特效节点),不能单独作为服务端证据
##
## connection_epoch:首个连接为 1;收到 disconnected 或在同一 epoch 内再次
## entered_game(快速重连 / 换服不一定发 disconnected)时加一。VID 只在其 epoch
## 内有意义,状态机必须按新 epoch 重新绑定主角与目标。
signal observed(kind: String, payload: Dictionary)
var client: Node
var flow: Node
var connection_epoch := 0
var _connections: Array[Dictionary] = []
const SERVER_SIGNALS := {
"entered_game": "_on_entered_game",
"disconnected": "_on_disconnected",
"login_failed": "_on_login_failed",
"char_list": "_on_char_list",
"entity_main_set": "_on_entity_main_set",
"entity_spawned": "_on_entity_spawned",
"entity_despawned": "_on_entity_despawned",
"entity_moved": "_on_entity_moved",
"entity_dead": "_on_entity_dead",
"damage": "_on_damage",
"motion": "_on_motion",
"vitals_changed": "_on_vitals",
"points_changed": "_on_points_changed",
"target_info": "_on_target_info",
"ground_item_added": "_on_ground_item_added",
"ground_item_removed": "_on_ground_item_removed",
"item_picked_up": "_on_item_picked_up",
"inventory_changed": "_on_inventory_changed",
"affect_added": "_on_affect_added",
"effect_cue": "_on_effect_cue",
"fly_cue": "_on_fly_cue",
"fly_targeting": "_on_fly_targeting",
}
func setup(app_flow: Node, m2client: Node) -> void:
var client: Object
var flow: Object
var connection_epoch := 1
var _entered_in_epoch := false
var _connections: Array[Dictionary] = []
var _last_sp := -1
func setup(app_flow: Object, m2client: Object) -> void:
flow = app_flow
client = m2client
if client == null:
return
_bind("entered_game", Callable(self, "_on_entered_game"))
_bind("disconnected", Callable(self, "_on_disconnected"))
_bind("char_list", Callable(self, "_on_char_list"))
_bind("entity_spawned", Callable(self, "_on_entity_spawned"))
_bind("entity_despawned", Callable(self, "_on_entity_despawned"))
_bind("entity_moved", Callable(self, "_on_entity_moved"))
_bind("entity_dead", Callable(self, "_on_entity_dead"))
_bind("damage", Callable(self, "_on_damage"))
_bind("vitals_changed", Callable(self, "_on_vitals"))
_bind("ground_item_added", Callable(self, "_on_ground_item_added"))
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed"))
_bind("item_picked_up", Callable(self, "_on_item_picked_up"))
_bind("inventory_changed", Callable(self, "_on_inventory_changed"))
_bind("effect_cue", Callable(self, "_on_effect_cue"))
_bind("fly_cue", Callable(self, "_on_fly_cue"))
_bind("fly_targeting", Callable(self, "_on_fly_targeting"))
for signal_name in SERVER_SIGNALS:
_bind(client, signal_name, Callable(self, SERVER_SIGNALS[signal_name]))
## Subscribe to a local presentation object (quickbar / skill fx). Its events
## are tagged source=local and never satisfy a server-evidence requirement.
func watch_local(source: Object, signal_name: String, kind: String) -> void:
if source == null or not source.has_signal(signal_name):
return
var callback := func(a: Variant = null, b: Variant = null, c: Variant = null,
d: Variant = null, e: Variant = null) -> void:
_emit_local(kind, [a, b, c, d, e])
_bind(source, signal_name, callback)
func snapshot() -> Dictionary:
var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game()
var main_vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0
var entity_count: int = client.get_entities().size() if client != null and client.has_method("get_entities") else 0
var ground_count: int = client.get_ground_items().size() if client != null and client.has_method("get_ground_items") else 0
var state := int(flow.state()) if flow != null and flow.has_method("state") else -1
var alive := client != null and is_instance_valid(client)
var in_game: bool = alive and client.has_method("is_in_game") and client.is_in_game()
var main_vid := int(client.get_main_vid()) if alive and client.has_method("get_main_vid") else 0
var entity_count: int = client.get_entities().size() if alive and client.has_method("get_entities") else 0
var ground_count: int = client.get_ground_items().size() if alive and client.has_method("get_ground_items") else 0
var flow_alive := flow != null and is_instance_valid(flow)
var state := int(flow.state()) if flow_alive and flow.has_method("state") else -1
return {"in_game": in_game, "main_vid": main_vid, "entity_count": entity_count,
"ground_item_count": ground_count, "app_state": state, "connection_epoch": connection_epoch}
"ground_item_count": ground_count, "app_state": state}
func disconnect_all() -> void:
for entry in _connections:
var source: Object = entry.source.get_ref()
var signal_name: String = entry.signal
var callback: Callable = entry.callback
if client != null and client.has_signal(signal_name) and client.is_connected(signal_name, callback):
client.disconnect(signal_name, callback)
if source != null and is_instance_valid(source) and source.has_signal(signal_name) \
and source.is_connected(signal_name, callback):
source.disconnect(signal_name, callback)
_connections.clear()
func bound_count() -> int:
_prune_connections()
return _connections.size()
func _prune_connections() -> void:
for i in range(_connections.size() - 1, -1, -1):
if _connections[i].source.get_ref() == null:
_connections.remove_at(i)
func _exit_tree() -> void:
disconnect_all()
func _bind(signal_name: String, callback: Callable) -> void:
if client.has_signal(signal_name) and not client.is_connected(signal_name, callback):
client.connect(signal_name, callback)
_connections.append({"signal": signal_name, "callback": callback})
func _bind(source: Object, signal_name: String, callback: Callable) -> void:
# Registries are RefCounted and own parsed effect caches. Observing a scene
# must not keep it alive after reconnect; discard dead bindings on rebind.
_prune_connections()
if source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
source.connect(signal_name, callback)
_connections.append({"source": weakref(source), "signal": signal_name, "callback": callback})
func _emit(kind: String, payload := {}) -> void:
func _emit(kind: String, payload := {}, actor_vid := 0, target_vid := 0) -> void:
var data: Dictionary = payload.duplicate(true)
data["source"] = "server"
data["connection_epoch"] = connection_epoch
data["actor_vid"] = actor_vid
data["target_vid"] = target_vid
data["received_us"] = Time.get_ticks_usec()
observed.emit(kind, data)
func _emit_local(kind: String, args: Array) -> void:
var data := {"source": "local", "connection_epoch": connection_epoch,
"actor_vid": 0, "target_vid": 0, "received_us": Time.get_ticks_usec()}
match kind:
"cast_started":
data["skill_id"] = int(args[0]) if args[0] != null else 0
data["target_vid"] = int(args[1]) if args[1] != null else 0
"skill_rejected":
data["skill_id"] = int(args[0]) if args[0] != null else 0
data["code"] = String(args[1]) if args[1] != null else ""
"fx_spawned", "fx_finished":
# EffectRegistry lifecycle: (effect, fx_id, lifetime_ms[, elapsed_ms, reason]).
data["effect"] = String(args[0]).get_file().get_basename().left(80) if args[0] != null else ""
data["fx_id"] = int(args[1]) if args[1] != null else 0
data["lifetime_ms"] = int(args[2]) if args[2] != null else -1
if kind == "fx_finished":
data["elapsed_ms"] = int(args[3]) if args[3] != null else -1
data["reason"] = String(args[4]) if args[4] != null else ""
_:
if args[0] is int:
data["skill_id"] = args[0]
observed.emit(kind, data)
func _on_entered_game() -> void:
connection_epoch += 1
if _entered_in_epoch:
connection_epoch += 1
_entered_in_epoch = true
_last_sp = -1
_emit("entered_game")
func _on_disconnected(reason: String) -> void:
_emit("disconnected", {"reason": reason})
_emit("disconnected", {"reason": reason.left(120)})
connection_epoch += 1
_entered_in_epoch = false
_last_sp = -1
func _on_login_failed(reason: String) -> void:
_emit("login_failed", {"reason": reason.left(120)})
func _on_char_list(chars: Array) -> void:
_emit("char_list", {"count": chars.size()})
var slots: Array = []
for character in chars:
if character is Dictionary:
slots.append(int(character.get("index", -1)))
_emit("char_list", {"count": chars.size(), "slots": slots})
func _on_entity_main_set(vid: int) -> void:
_emit("entity_main_set", {}, vid)
func _on_entity_spawned(entity: Dictionary) -> void:
_emit("entity_spawned", {"vid": int(entity.get("vid", 0)), "race": int(entity.get("race", 0)), "kind": int(entity.get("kind", -1))})
_emit("entity_spawned", {"race": int(entity.get("race", 0)), "ch_type": int(entity.get("ch_type", -1)),
"dead": bool(entity.get("dead", false)), "pos_cm": entity.get("pos_cm", Vector3.ZERO)},
int(entity.get("vid", 0)))
func _on_entity_despawned(vid: int) -> void:
_emit("entity_despawned", {"vid": vid})
_emit("entity_despawned", {}, vid)
func _on_entity_moved(vid: int) -> void:
_emit("entity_moved", {"vid": vid})
var pos: Variant = Vector3.ZERO
if client != null and client.has_method("get_entity"):
pos = client.get_entity(vid).get("pos_cm", Vector3.ZERO)
_emit("entity_moved", {"pos_cm": pos}, vid)
func _on_entity_dead(vid: int) -> void:
_emit("entity_dead", {"vid": vid})
_emit("entity_dead", {}, 0, vid)
func _on_damage(vid: int, amount: int, flag: int) -> void:
_emit("damage", {"vid": vid, "amount": amount, "flag": flag})
_emit("damage", {"amount": amount, "flag": flag}, 0, vid)
func _on_motion(vid: int, victim_vid: int, motion: int) -> void:
_emit("server_motion", {"motion": motion}, vid, victim_vid)
func _on_vitals(vid: int) -> void:
_emit("vitals_changed", {"vid": vid})
var entity: Dictionary = client.get_entity(vid) if client != null and client.has_method("get_entity") else {}
_emit("vitals_changed", {"hp": int(entity.get("hp", -1)), "max_hp": int(entity.get("max_hp", -1)),
"dead": bool(entity.get("dead", false))}, vid)
func _on_points_changed(points: Dictionary) -> void:
# points_changed carries the main character's point table. Only a change in
# SP is interesting as skill-resource evidence.
var sp := int(points.get("sp", points.get(8, -1))) if points.has("sp") or points.has(8) else -1
if sp >= 0 and _last_sp >= 0 and sp != _last_sp:
_emit("sp_changed", {"before": _last_sp, "after": sp, "delta": sp - _last_sp})
if sp >= 0:
_last_sp = sp
func _on_target_info(vid: int, hp_percent: int) -> void:
_emit("target_info", {"hp_percent": hp_percent}, 0, vid)
func _on_ground_item_added(item: Dictionary) -> void:
_emit("ground_item_added", {"vid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), "owner": String(item.get("owner", ""))})
_emit("ground_item_added", {"iid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)),
"owner_match": _owner_matches(String(item.get("owner", ""))), "pos_cm": _ground_pos_cm(item)})
func _on_ground_item_removed(vid: int) -> void:
_emit("ground_item_removed", {"vid": vid})
_emit("ground_item_removed", {"iid": vid})
func _on_item_picked_up(vnum: int, count: int, source: String) -> void:
_emit("item_picked_up", {"vnum": vnum, "count": count, "source": source})
# `source` may be a player name; keep only whether it was attributed at all.
_emit("item_picked_up", {"vnum": vnum, "count": count, "selected": not source.is_empty()})
func _on_inventory_changed(window: int, cell: int) -> void:
_emit("inventory_changed", {"window": window, "cell": cell})
func _on_affect_added(affect: Dictionary) -> void:
_emit("affect_added", {"type": int(affect.get("type", 0))})
func _on_effect_cue(vid: int, name: String, special: int) -> void:
_emit("effect_cue", {"vid": vid, "name": name, "special": special})
_emit("effect_cue", {"effect": name.get_file(), "special": special}, vid)
func _on_fly_cue(kind: int, start_vid: int, end_vid: int) -> void:
_emit("fly_cue", {"type": kind, "start_vid": start_vid, "end_vid": end_vid})
_emit("fly_cue", {"type": kind}, start_vid, end_vid)
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void:
_emit("fly_targeting", {"shooter_vid": shooter_vid, "target_vid": target_vid,
"target_cm": [target_cm.x, target_cm.y], "append": append})
func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, _append: bool) -> void:
_emit("fly_targeting", {"pos_cm": target_cm}, shooter_vid, target_vid)
## Ownership is evaluated in-process so the owner's name never reaches a report:
## an empty owner is free-for-all, otherwise it must equal the main character.
func _owner_matches(owner: String) -> bool:
if owner.is_empty():
return true
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
return false
var main: Dictionary = client.get_entity(int(client.get_main_vid()))
return String(main.get("name", "")) == owner
func _ground_pos_cm(item: Dictionary) -> Vector2:
var pos: Variant = item.get("pos", null)
if pos is Vector3:
# M2Client ground pos is Godot metres (x, y-up, -y_cm); convert back to server cm.
return Vector2(roundf(pos.x * 100.0), roundf(-pos.z * 100.0))
return Vector2(-1, -1)
+142 -16
View File
@@ -2,15 +2,54 @@ class_name PlayableReport
## 联网测试进程内的报告构建器。
## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。
##
## 事件契约(events.jsonl 每行):monotonic_us、run_id、case_id、connection_epoch、
## stage、kind、actor_vid、target_vid、payload。payload 只保留 PAYLOAD_KEYS
## 白名单字段;其它字段丢弃并计数,避免把角色名、聊天内容或环境变量写进报告。
const SCHEMA_VERSION := 1
const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"]
const EVENT_KEYS := ["monotonic_us", "run_id", "case_id", "connection_epoch", "stage",
"kind", "actor_vid", "target_vid", "payload"]
const PAYLOAD_KEYS := [
"source", "count", "race", "ch_type", "amount", "flag", "vnum", "window", "cell",
"special", "type", "motion", "reason_code", "hp", "max_hp", "hp_percent", "dead",
"pos_cm", "local_pos_cm", "distance_cm", "tolerance_cm", "waypoint_index", "skill_id",
"code", "state", "map_key", "expected_map_key", "iid", "before", "after", "delta",
"attempt", "owner_match", "allow_gameplay", "main_vid", "entity_count",
"ground_item_count", "app_state", "scene_ready", "hud_ready", "player_ready",
"in_game", "reconnecting", "character_count", "slot_present", "stale_epoch",
"elapsed_ms", "timeout_ms", "server_index", "channel", "address_match", "evidence",
"screenshot", "suite", "effect", "lifetime_ms", "selected", "accepted", "reason", "slots",
"fx_id", "open_effects", "phase",
# STB-01 soak
"round", "wall_ms", "requested_size", "logical_size", "physical_size", "viewport_size",
"screen_scale", "duplicate_vids", "target_residue", "portal_cm", "unreachable_seconds",
"cross_server", "interaction", "severe", "elapsed_s", "leg", "required", "size",
]
## Top-level report sections a suite may attach; anything else is rejected.
const SECTIONS := ["soak", "frame_metrics", "client_memory", "probe_overhead"]
## Values that look like credentials are masked even inside whitelisted keys.
const SECRET_KEYS := ["password", "passwd", "token", "secret", "account", "login", "username"]
var _report: Dictionary = {}
var _started_us := 0
var _events_path := ""
var _event_count := 0
var _dropped_payload_keys := {}
var _redact_literals: Array[String] = []
func begin(run_id: String, suite: String, config: Dictionary) -> void:
func begin(run_id: String, suite: String, config: Dictionary, events_path := "") -> void:
_started_us = Time.get_ticks_usec()
_events_path = events_path
_event_count = 0
_dropped_payload_keys = {}
# Account/password literals may appear inside server messages; never persist them.
_redact_literals.clear()
for name in ["MT_ACCOUNT", "MT_PASSWORD"]:
var literal := OS.get_environment(name)
if literal.length() >= 2:
_redact_literals.append(literal)
_report = {
"schema_version": SCHEMA_VERSION,
"run_id": run_id,
@@ -19,7 +58,9 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void:
"build": _build_info(),
"environment": _environment_info(),
"cases": [],
"events": "events.jsonl",
"required_cases": [],
"events": events_path.get_file() if not events_path.is_empty() else "",
"event_count": 0,
"failures": [],
"blocked": [],
"coverage": {"required": 0, "passed": 0},
@@ -28,37 +69,97 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void:
# Keep configuration useful for reproducing a run without copying any secret.
_report["scenario_id"] = String(config.get("scenario_id", ""))
## Declares the case IDs that must appear; missing ones become BLOCKED on finish().
func set_required_cases(ids: Array) -> void:
var out: Array = []
for id in ids:
out.append(String(id))
_report.required_cases = out
func required_cases() -> Array:
return _report.get("required_cases", []).duplicate()
func has_case(case_id: String) -> bool:
for item in _report.get("cases", []):
if String(item.id) == case_id:
return true
return false
func case_status(case_id: String) -> String:
for item in _report.get("cases", []):
if String(item.id) == case_id:
return String(item.status)
return ""
func add_case(case_id: String, status: String, reason := "", evidence: Array = [], duration_ms := 0.0) -> void:
if has_case(case_id):
_fail("duplicate case result for %s" % case_id)
return
var normalized := status.to_upper()
if not VALID_STATUSES.has(normalized):
normalized = "FAIL"
_fail("invalid case status for %s" % case_id)
var item := {"id": case_id, "status": normalized, "duration_ms": duration_ms,
"reason": reason, "evidence": _sanitize(evidence)}
"reason": _redact_text(reason), "evidence": _sanitize(evidence)}
_report.cases.append(item)
_report.coverage.required = int(_report.coverage.required) + 1
if normalized == "PASS":
_report.coverage.passed = int(_report.coverage.passed) + 1
elif normalized == "FAIL":
_fail("%s: %s" % [case_id, reason if not reason.is_empty() else "failed"])
_fail("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "failed"])
elif normalized == "BLOCKED":
_report.blocked.append(reason if not reason.is_empty() else case_id)
_report.blocked.append("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "blocked"])
func add_event(event: Dictionary, events_path: String) -> void:
var safe: Variant = _sanitize(event)
var file := FileAccess.open(events_path, FileAccess.READ_WRITE)
## A run-level failure that belongs to no open case (e.g. an effect from an
## already-passed case outliving its lifetime). Any failure makes the run FAIL.
func add_failure(message: String) -> void:
_fail(message)
## Attach a whitelisted top-level section (sanitized like payloads). Returns false for unknown names.
func set_section(name: String, value: Dictionary) -> bool:
if _report.is_empty() or not (name in SECTIONS):
return false
_report[name] = _sanitize(value)
return true
func failures() -> Array:
return _report.get("failures", []).duplicate()
## Append one contract event. Returns the event actually written (for tests).
func record(kind: String, stage: String, case_id: String, connection_epoch: int,
actor_vid := 0, target_vid := 0, payload := {}) -> Dictionary:
var event := {
"monotonic_us": Time.get_ticks_usec(),
"run_id": String(_report.get("run_id", "")),
"case_id": case_id,
"connection_epoch": connection_epoch,
"stage": stage,
"kind": kind,
"actor_vid": actor_vid,
"target_vid": target_vid,
"payload": _whitelist(payload),
}
_event_count += 1
_report.event_count = _event_count
if _events_path.is_empty():
return event
var file := FileAccess.open(_events_path, FileAccess.READ_WRITE)
if file == null:
file = FileAccess.open(events_path, FileAccess.WRITE)
file = FileAccess.open(_events_path, FileAccess.WRITE)
if file == null:
_fail("cannot open events file")
return
return event
file.seek_end()
file.store_line(JSON.stringify(safe))
file.store_line(JSON.stringify(event))
file.close()
return event
func finish() -> Dictionary:
if _report.is_empty():
return {}
for required in _report.required_cases:
if not has_case(String(required)):
add_case(String(required), "BLOCKED", "not_reached")
var required := int(_report.coverage.required)
var passed := int(_report.coverage.passed)
var failures: Array = _report.failures
@@ -68,6 +169,8 @@ func finish() -> Dictionary:
_report.status = "BLOCKED"
else:
_report.status = "PASS"
if not _dropped_payload_keys.is_empty():
_report["dropped_payload_keys"] = _dropped_payload_keys.keys()
_report.duration_seconds = float(Time.get_ticks_usec() - _started_us) / 1000000.0
return _report
@@ -83,7 +186,7 @@ func write(path: String) -> bool:
return true
func _fail(message: String) -> void:
_report.failures.append(message)
_report.failures.append(_redact_text(message))
func _build_info() -> Dictionary:
return {
@@ -94,19 +197,40 @@ func _build_info() -> Dictionary:
}
func _environment_info() -> Dictionary:
var size := DisplayServer.window_get_size() if DisplayServer.get_name() != "headless" else Vector2i.ZERO
var headless := DisplayServer.get_name() == "headless"
var size := DisplayServer.window_get_size() if not headless else Vector2i.ZERO
var logical := size
if not headless and DisplayServer.screen_get_scale() > 0.0:
logical = Vector2i(roundi(size.x / DisplayServer.screen_get_scale()), roundi(size.y / DisplayServer.screen_get_scale()))
return {
"os": OS.get_name(),
"renderer": RenderingServer.get_video_adapter_name(),
"resolution": [size.x, size.y],
"logical_resolution": [logical.x, logical.y],
}
func _whitelist(payload: Dictionary) -> Dictionary:
var out := {}
for key in payload:
var name := String(key)
if name in PAYLOAD_KEYS:
out[name] = _sanitize(payload[key])
else:
_dropped_payload_keys[name] = true
return out
func _redact_text(text: String) -> String:
var out := text
for literal in _redact_literals:
out = out.replace(literal, "[redacted]")
return out
func _sanitize(value: Variant) -> Variant:
if value is Dictionary:
var out := {}
for key in value:
var name := String(key).to_lower()
if name in ["password", "passwd", "token", "secret", "account"]:
if name in SECRET_KEYS:
out[key] = "[redacted]"
else:
out[key] = _sanitize(value[key])
@@ -116,9 +240,11 @@ func _sanitize(value: Variant) -> Variant:
for item in value:
out_array.append(_sanitize(item))
return out_array
if value is Vector2:
if value is String or value is StringName:
return _redact_text(String(value))
if value is Vector2 or value is Vector2i:
return [value.x, value.y]
if value is Vector3:
if value is Vector3 or value is Vector3i:
return [value.x, value.y, value.z]
if value is Object:
return "[object]"
+65
View File
@@ -0,0 +1,65 @@
extends RefCounted
## STB-RESIZE-01 的真实窗口适配器(PlayableFlow.window)。
##
## macOS Retina 下 DisplayServer 的窗口尺寸是物理像素,逻辑尺寸 = 物理像素 / 屏幕缩放。
## 流程只按逻辑尺寸判定;物理像素、缩放和视口尺寸一并写进事件,Retina 的 2x 不是分辨率错误。
## 请求尺寸超出当前屏幕可用区域(含标题栏)时如实拒绝(request_logical_size 返回 false),
## 由流程记为 BLOCKED,不缩小目标冒充通过。headless / 全屏 / 最大化窗口不可调整。
var _viewport: Viewport
func _init(viewport: Viewport = null) -> void:
_viewport = viewport
func availability() -> Dictionary:
if DisplayServer.get_name() == "headless":
return {"ok": false, "reason": "headless display server has no window"}
match DisplayServer.window_get_mode():
DisplayServer.WINDOW_MODE_FULLSCREEN, DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN:
return {"ok": false, "reason": "window is fullscreen; logical size switching needs windowed mode"}
DisplayServer.WINDOW_MODE_MAXIMIZED:
return {"ok": false, "reason": "window is maximized; logical size switching needs windowed mode"}
DisplayServer.WINDOW_MODE_MINIMIZED:
return {"ok": false, "reason": "window is minimized"}
if screen_scale() <= 0.0:
return {"ok": false, "reason": "screen scale unavailable"}
return {"ok": true, "reason": ""}
func screen_scale() -> float:
return DisplayServer.screen_get_scale(DisplayServer.window_get_current_screen())
func physical_size() -> Vector2i:
return DisplayServer.window_get_size()
func logical_size() -> Vector2i:
var scale := screen_scale()
var size := physical_size()
if scale <= 0.0:
return size
return Vector2i(roundi(size.x / scale), roundi(size.y / scale))
func viewport_size() -> Vector2i:
if _viewport == null or not is_instance_valid(_viewport):
return Vector2i.ZERO
return Vector2i(_viewport.get_visible_rect().size)
## Returns false (no request sent) when the size cannot fit the usable screen area.
func request_logical_size(size: Vector2i) -> bool:
var scale := screen_scale()
if scale <= 0.0 or size.x <= 0 or size.y <= 0:
return false
var target := Vector2i(roundi(size.x * scale), roundi(size.y * scale))
var screen := DisplayServer.window_get_current_screen()
var usable := DisplayServer.screen_get_usable_rect(screen)
var decorations := DisplayServer.window_get_size_with_decorations() - DisplayServer.window_get_size()
if target.x + decorations.x > usable.size.x or target.y + decorations.y > usable.size.y:
return false
DisplayServer.window_set_size(target)
# Keep the whole window on the usable area so the OS does not clamp the size back.
var position := DisplayServer.window_get_position()
var clamped := Vector2i(clampi(position.x, usable.position.x, usable.end.x - target.x - decorations.x),
clampi(position.y, usable.position.y, usable.end.y - target.y - decorations.y))
if clamped != position:
DisplayServer.window_set_position(clamped)
return true
+42 -12
View File
@@ -5,8 +5,10 @@
# em.setup(m2client, item_list, model_getter, assets_root, race)
#
# 监听 M2Client.inventory_changed(window == EQUIPMENT),读 get_equipment()
# WEAR_WEAPON(4) -> model.weapon_gr2 = item_list.model(vnum) 解析后的真实路径
# WEAR_SHIELD(10)-> model.shield_gr2左手刚体挂点,同 weapon
# WEAR_WEAPON(4) -> item_list.model(vnum) 解析后的真实路径,按左右手分给
# model.weapon_gr2PART_WEAPON/ model.shield_gr2PART_WEAPON_LEFT
# ActorInstanceAttach.cpp AttachWeapon:匕首双手、弓左手、骑马的扇双手)
# WEAR_SHIELD(10)-> 不渲染(参考端没有盾牌挂件,item_list 的盾也没有模型)
# WEAR_HEAD(1) -> 有模型的头盔 = 覆盖发型槽(Metin2 约定:头防替换头发)
# WEAR_BODY(0) -> race_spec.shape(armor_shape_of(vnum)) -> model.gr2_path (+ 换肤)
# armor_model_map[vnum] 若给了则优先
@@ -34,6 +36,12 @@ const PART_WEAPON := 1
const PART_HEAD := 2
const PART_HAIR := 3
const CHR_EQUIPPART_HAIR := 3 # 兼容旧引用
# item_length.h EWeaponSubTypesCItemData::GetWeaponType = bSubType
const WEAPON_SUB_DAGGER := 1
const WEAPON_SUB_BOW := 2
const WEAPON_SUB_FAN := 5
const HAND_RIGHT := 1
const HAND_LEFT := 2
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
var client: Node
@@ -47,7 +55,7 @@ var _spec_tried := false
var _last_weapon_vnum := -1
var _last_body_vnum := -1
var _last_head_vnum := -2 # -1 是「发型被遮挡」的有效态,用 -2 当「未应用」哨兵
var _last_shield_vnum := -1
var _last_weapon_hands := -1
var _last_hair_part := -1
var main_getter: Callable = Callable() # func() -> int:主角 vid(取 parts 用)
var _remote_mode := false
@@ -77,6 +85,11 @@ func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "",
client.entity_info.connect(func(v, _d):
if main_getter.is_valid() and int(main_getter.call()) == v:
refresh())
# 上 / 下马改变扇子的左右手(__IsLeftHandWeapon: FAN && IsMountingHorse
if client and client.has_signal("mount_changed"):
client.mount_changed.connect(func(v):
if main_getter.is_valid() and int(main_getter.call()) == v:
refresh())
# 远端 PC 不读取本地 get_equipment();参考端直接消费
# GC_CHAR_ADDITIONAL_INFO / GC_CHARACTER_UPDATE 的 awPart[4]。
@@ -139,17 +152,34 @@ func refresh() -> void:
# --- 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON;过 PartHiding 遮挡 ---------
var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON)
var wpn_eff := PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly)
if wpn_eff != _last_weapon_vnum:
var hands := _weapon_hands(wpn_eff)
if wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands:
_last_weapon_vnum = wpn_eff
model.set("weapon_gr2", _resolve_weapon(wpn_eff))
_last_weapon_hands = hands
# 左手用 GetSubModelThing()item .msm 加载在参考端被注释掉,恒为同一个 gr2
var wpath := _resolve_weapon(wpn_eff)
model.set("weapon_gr2", wpath if hands & HAND_RIGHT else "")
model.set("shield_gr2", wpath if hands & HAND_LEFT else "")
# --- 盾(左手挂点,同武器遮挡规则)--------------------------------------------
if eq.size() > WEAR_SHIELD:
var sh_vnum := int(eq[WEAR_SHIELD].get("vnum", 0))
var sh_eff := 0 if PartHiding.weapon_hidden(raw_shape, is_poly, sh_vnum) else sh_vnum
if sh_eff != _last_shield_vnum:
_last_shield_vnum = sh_eff
model.set("shield_gr2", _resolve_weapon(sh_eff))
# ActorInstanceAttach.cpp __IsRightHandWeapon / __IsLeftHandWeapon -> HAND_* 位掩码
func _weapon_hands(vnum: int) -> int:
if vnum == 0:
return 0
var sub := -1
if proto and proto.has_method("item"):
sub = int(proto.item(vnum).get("sub_type", -1))
if sub == WEAPON_SUB_DAGGER or (sub == WEAPON_SUB_FAN and _is_mounting()):
return HAND_RIGHT | HAND_LEFT
if sub == WEAPON_SUB_BOW:
return HAND_LEFT
return HAND_RIGHT
# IsMountingHorse:主角实体 mount_vnum 非 0。远端 PC 暂无坐骑状态输入(seam)。
func _is_mounting() -> bool:
if _remote_mode or not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
return false
var vid: int = main_getter.call()
return vid != 0 and int(client.get_entity(vid).get("mount_vnum", 0)) != 0
# §4.1 修改 4 seam40250 实体表暂无变身字段(m2_client.cpp entity dict 无 poly_race /
# polymorph)。一旦 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带上变身 race,这里改成读
+127
View File
@@ -0,0 +1,127 @@
# HitReaction —— CLIENT-GAP §3.7 受击方视图(PlayerView / MobView)共用的受击状态机。
# __HitGood / __HitGreate / __HitStoneActorInstanceBattle.cpp)的分支判定、
# __Shake + ShakeProcess(世界矩阵平移 ±rand()%10 cm)、CGraphicThingInstance::InsertDelay、
# InterceptOnceMotion + PushOnceMotion + PushLoopMotion 的动作链。
# 视图只提供「按状态名绑定一段一次性动作」的 Callablebind(step) -> bool,资源缺失 = false
# 等同 GetMotionKey 失败时 InterceptOnceMotion / PushOnceMotion 返回 FALSE)。
extends RefCounted
const SHAKE_TIME := 0.1 # __Shake(100)m_dwShakeTime = now + 100 ms
const SHAKE_RAND_CM := 10 # ShakeProcessrand() % 10
const LOOP_STATES := ["wait", "walk", "run"]
const DAMAGE_STATES := ["damage", "damage_back"] # IsDamage 类
const KNOCKDOWN_STATES := ["knockdown", "knockdown_back"] # IsKnockDownDAMAGE_FLYING(_BACK)
const STANDUP_STATES := ["standup", "standup_back"] # __IsStandUpMotionSTAND_UP(_BACK)
# CActorInstance::isLockNORMAL_ATTACK / COMBO_ATTACK_* / SPECIAL_* / 钓鱼 / 表情跳舞等
const LOCK_STATES := ["attack", "combo", "skill", "__motion", "emotion", "dig",
"fishing", "fishing_react", "fishing_catch", "fishing_fail"]
const SKILL_STATES := ["skill"] # IsUsingSkillSKILL 段 / SPECIAL_1..6
var delay_left := 0.0
var speed := 1.0 # 当前动作的 fSpeedRatioInsertDelay 结束后恢复)
var shake_left := 0.0
var queue: Array = [] # 剩余的 PushOnceMotion 步
var resume := "wait" # 链尾 PushLoopMotion 的循环动作
var active := false # 受击动作链正在播
var real_dead := false # m_isRealDead:晕眩击倒后不起身
var _shaking := false
var _shake_base := Vector3.ZERO
static func is_reaction(state: String) -> bool:
return state in DAMAGE_STATES or state in KNOCKDOWN_STATES or state in STANDUP_STATES
# __HitGood:返回候选动作链(按顺序尝试,首步绑定成功的那条生效)。
func good(state: String, scalar: float, stunned: bool) -> Array:
if state in KNOCKDOWN_STATES:
return []
if stunned:
return [] # IsStun() -> Die():死亡动作由服务器驱动(seam)
shake()
if state in LOCK_STATES:
return []
if scalar < 0.0:
return [["damage"]]
return [["damage_back"], ["damage"]]
# __HitGreate
func greate(state: String, scalar: float, stunned: bool) -> Array:
if state in KNOCKDOWN_STATES or state in STANDUP_STATES:
return []
shake()
if state in SKILL_STATES:
return []
if stunned:
if scalar < 0.0:
return [["knockdown"]]
return [["knockdown_back"], ["knockdown"]]
if scalar < 0.0:
return [["knockdown", "standup"]]
return [["knockdown_back", "standup_back"], ["knockdown", "standup"]]
# __HitStone
func stone(stunned: bool) -> void:
if not stunned:
shake()
func shake() -> void:
shake_left = SHAKE_TIME
func insert_delay(d: float, anim: Object) -> void:
if d <= 0.0:
return
delay_left = d
if anim:
anim.set("time_scale", 0.0)
# 绑定新动作时的 time_scaleInsertDelay 期间保持冻结。
func scale_for(ratio: float) -> float:
speed = ratio
return 0.0 if delay_left > 0.0 else ratio
# 首步绑定成功 -> 记下链尾要回的循环动作,剩余步入队。
func start(chains: Array, current: String, bind: Callable, dead_after := false) -> bool:
for chain: Array in chains:
if chain.is_empty() or not bool(bind.call(String(chain[0]))):
continue
if not is_reaction(current):
resume = current if current in LOOP_STATES else "wait"
queue = chain.slice(1)
active = true
real_dead = dead_after
return true
return false
# 一段受击动作播完:绑下一步(缺资源的步跳过);返回 false = 链结束。
func advance(bind: Callable) -> bool:
while not queue.is_empty():
if bool(bind.call(String(queue.pop_front()))):
return true
active = false
return false
func cancel() -> void:
active = false
queue.clear()
real_dead = false
func tick(dt: float, anim: Object, model: Node3D) -> void:
if delay_left > 0.0:
delay_left -= dt
if delay_left <= 0.0 and anim:
anim.set("time_scale", speed)
if shake_left > 0.0:
shake_left -= dt
if model == null:
return
if shake_left > 0.0:
if not _shaking:
_shaking = true
_shake_base = model.position
model.position = _shake_base + Vector3(_rand_cm(), _rand_cm(), _rand_cm()) / 100.0
elif _shaking:
_shaking = false
model.position = _shake_base
static func _rand_cm() -> float:
var v := float(randi() % SHAKE_RAND_CM)
return v if randi() % 2 == 0 else -v
+150 -19
View File
@@ -21,13 +21,31 @@ const STATE_MOTIONS := {
"damage": ["FRONT_DAMAGE", "DAMAGE", "BACK_DAMAGE"],
"dead": ["FRONT_DEAD", "DEAD", "BACK_DEAD"],
"emotion": ["WAIT"],
# §3.7 受击动作步(motlist 名 -> NAME_DAMAGE_BACK / DAMAGE_FLYING(_BACK) / STAND_UP(_BACK))。
# 不加别名:缺哪段就让 InterceptOnceMotion / PushOnceMotion 失败,与参考端一致。
"damage_back": ["BACK_DAMAGE"],
"knockdown": ["FRONT_KNOCKDOWN"],
"knockdown_back": ["BACK_KNOCKDOWN"],
"standup": ["FRONT_STANDUP"],
"standup_back": ["BACK_STANDUP"],
}
const HitReaction = preload("res://ui/hit_reaction.gd")
const MsaMotion = preload("res://ui/msa_motion.gd")
const MOVE_STATES := ["walk", "run"]
const HitCollision = preload("res://hit_collision.gd")
# CActorInstance::__SetMotion 尾:动作绑定完成。
signal motion_bound(state: String)
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
var _dir := ""
var _mesh_stem := ""
var _hit := HitReaction.new()
var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径
var _state := ""
var _move_speed_ratio := 1.0
var _last_resolution := {}
var _audio: Node
var _sound_instances: Array = []
var _sound_frame := -1
@@ -88,6 +106,7 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
break
if _dir == "":
return false
_mesh_stem = mesh_stem
var gr2 := _dir.path_join(mesh_stem + ".gr2")
if not FileAccess.file_exists(gr2):
gr2 = _dir.path_join(_dir.get_file() + ".gr2") # 目录同名主网格
@@ -113,6 +132,8 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
anim.set("model_path", NodePath("../Metin2Model"))
anim.set("blend_time", 0.15)
add_child(anim)
if anim.has_signal("playback_finished"):
anim.playback_finished.connect(_on_playback_finished)
set_anim_state("wait")
CharShadow.attach(self, 1.6) # 怪脚印大一点
return true
@@ -124,21 +145,142 @@ func set_audio(audio_node: Node) -> void:
func set_anim_state(s: String) -> void:
if s == _state or anim == null:
return
_state = s
var msa := _motion_for(s)
if msa == "":
if HitReaction.is_reaction(s):
_hit.start([[s]], _state, _bind_reaction)
return
anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", msa)
_refresh_sound_script(msa)
# 受击动作链播放中:循环动作请求留到链尾 PushLoopMotion 再回(seam:参考端动作队列)
if _hit.active and s in HitReaction.LOOP_STATES:
_hit.resume = s
return
_hit.cancel()
_state = s
_last_resolution = resolve_motion(s)
var msa := String(_last_resolution.path)
if msa == "":
# 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。
return
_bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
# CActorInstance::SetMoveSpeedm_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
# Move -> SetLoopMotion(WALK/RUN, 0.15, m_fMovSpd))。>1100 时参考端不走动作累计,
# EntityStore 退回按 duration 插值,这里保持原速播放。
func set_move_speed(moving_speed: int) -> void:
_move_speed_ratio = 1.0 if moving_speed <= 0 or moving_speed > 1100 \
else float(moving_speed) / 100.0
if anim != null and _state in MOVE_STATES and not _hit.active:
anim.set("time_scale", _hit.scale_for(_move_speed_ratio))
func _loop_speed(state: String) -> float:
return _move_speed_ratio if state in MOVE_STATES else 1.0
## 走 / 跑动作的根运动速度(cm/smovSpd 100):x = walky = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
return Vector2(MsaMotion.move_speed(String(resolve_motion("walk").path)),
MsaMotion.move_speed(String(resolve_motion("run").path)))
# CGraphicThingInstance::InsertDelay(fStiffenTime)
func insert_delay(d: float) -> void:
_hit.insert_delay(d, anim)
# CActorInstance::__HitGood / __HitGreate / __HitStoneActorInstanceBattle.cpp
func hit_good(scalar: float, stunned: bool) -> void:
_hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction)
func hit_greate(scalar: float, stunned: bool) -> void:
_hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned)
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
func is_in_hit_reaction() -> bool:
return _hit.active
# 受击方防御球:怪目录的 .msm<网格代号>.msm / <目录名>.msm / 目录里第一个 .msm)。
func get_defending_spheres() -> Array:
if _dir == "":
return []
var cands: Array = []
if _mesh_stem != "":
cands.append(_dir.path_join(_mesh_stem + ".msm"))
cands.append(_dir.path_join(_dir.get_file() + ".msm"))
for p: String in cands:
if FileAccess.file_exists(p):
return HitCollision.parse_msm_defending(p)
var da := DirAccess.open(_dir)
if da:
for fn in da.get_files():
if fn.to_lower().ends_with(".msm"):
return HitCollision.parse_msm_defending(_dir.path_join(fn))
return []
func _on_playback_finished() -> void:
if not _hit.active:
return
if _hit.advance(_bind_reaction) or _hit.real_dead:
return
_state = ""
set_anim_state(_hit.resume)
func _bind_reaction(step: String) -> bool:
if anim == null:
return false
var r := resolve_motion(step)
_last_resolution = r
var msa := String(r.path)
if msa == "":
return false
_bind(step, msa, false, 1.0)
return true
func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not HitReaction.is_reaction(state):
_hit.cancel()
_state = state
anim.set("loop", loop)
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "") # 同一段一次性动作再触发:先清,否则原生播放器视为无变化
anim.set("anim_path", path)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
motion_bound.emit(state)
# Read-only motion resolver used by the forest acceptance harness. Returning
# the selected .msa before playback lets the test distinguish a real action
# from a silent "keep the previous animation" fallback.
func get_motion_path(state: String) -> String:
return _motion_for(state)
return String(resolve_motion(state).path)
func _process(_delta: float) -> void:
## requested_state / motion / path / fallback_reason。fallback_reason:
## "" 首选动作名命中
## "client_alias:<NAME>" 本客户端的同类动作别名(如 run -> WALK
## "motlist_missing:<file>" motlist 未列出,按目录里的 <name>.msa 兜底
## "reference_keep_current_motion" 资源没有任何候选动作;与旧客户端一致保留当前动作
func resolve_motion(state: String) -> Dictionary:
var names: Array = STATE_MOTIONS.get(state, [])
var out := {"requested_state": state, "motion": "", "path": "", "fallback_reason": ""}
for mo: String in names:
if _motions.has(mo):
out.motion = mo
out.path = String(_motions[mo])
if mo != String(names[0]):
out.fallback_reason = "client_alias:" + mo
return out
for mo: String in names:
var p := _dir.path_join(mo.to_lower() + ".msa")
if FileAccess.file_exists(p):
out.motion = mo
out.path = p
out.fallback_reason = "motlist_missing:" + p.get_file()
return out
out.fallback_reason = "reference_keep_current_motion"
return out
func last_motion_resolution() -> Dictionary:
return _last_resolution.duplicate()
func _process(delta: float) -> void:
_hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
or not bool(anim.get("playing")):
return
@@ -194,17 +336,6 @@ func _folder_candidates(proto: Node, race: int) -> Array:
out.append(c)
return out
func _motion_for(state: String) -> String:
for mo: String in STATE_MOTIONS.get(state, []):
if _motions.has(mo):
return _motions[mo]
# 兜底:目录里有 <mo>.msa 直接用
for mo: String in STATE_MOTIONS.get(state, []):
var p := _dir.path_join(mo.to_lower() + ".msa")
if FileAccess.file_exists(p):
return p
return _motions.get("WAIT", "")
func _load_motlist() -> void:
var ml := _dir.path_join("motlist.txt")
if not FileAccess.file_exists(ml):
+38
View File
@@ -0,0 +1,38 @@
# MsaMotion —— 读 .msa 文本里的根运动:移动动作每秒位移(cm/s,movSpd 100 时)。
#
# MotionDuration 0.600000
# Accumulation 0.00 -255.45 0.00
#
# 服务端 CHARACTER::GetMoveMotionSpeed = -Accumulation.y / MotionDuration
# 客户端 CActorInstance::AccumulationMovement 按同一累计量推进。这里取 xy 长度。
extends RefCounted
static var _cache := {} # path -> float
static func move_speed(path: String) -> float:
if path == "" or not path.to_lower().ends_with(".msa"):
return 0.0
if _cache.has(path):
return float(_cache[path])
var speed := 0.0
var f := FileAccess.open(path, FileAccess.READ)
if f:
var duration := 0.0
var accum := Vector2.ZERO
while not f.eof_reached():
var parts := _fields(f.get_line())
if parts.size() < 2:
continue
match String(parts[0]):
"MotionDuration":
duration = String(parts[1]).to_float()
"Accumulation":
if parts.size() >= 3:
accum = Vector2(String(parts[1]).to_float(), String(parts[2]).to_float())
if duration > 0.0:
speed = accum.length() / duration
_cache[path] = speed
return speed
static func _fields(line: String) -> PackedStringArray:
return line.strip_edges().replace("\t", " ").split(" ", false)
+152 -18
View File
@@ -13,22 +13,53 @@ extends Node3D
# 4=WAR_W 5=ASN_M 6=SURA_W 7=SHA_M
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
const FEMALE_RACES := [1, 3, 4, 6]
# playersettingmodule.py RegisterAttachingBoneName(按职业,男女同名):
# PART_WEAPON -> model.weapon_bonePART_WEAPON_LEFT -> model.shield_bone"" = 未登记,不挂)
const WEAPON_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"]
const WEAPON_LEFT_BONE := ["", "equip_left", "", "equip_left"]
const HitReaction = preload("res://ui/hit_reaction.gd")
const MsaMotion = preload("res://ui/msa_motion.gd")
const MOVE_STATES := ["walk", "run"]
const HitCollision = preload("res://hit_collision.gd")
# CRaceMotionData::MOTION_MODE_* -> playersettingmodule 注册动作用的目录(与 general 同级)
const MOTION_MODE_DIRS := {1: "general", 2: "onehand_sword", 3: "twohand_sword", 4: "dualhand_sword",
5: "bow", 6: "fan", 7: "bell", 8: "fishing", 9: "horse", 10: "horse_onehand_sword",
11: "horse_twohand_sword", 12: "horse_dualhand_sword", 13: "horse_bow", 14: "horse_fan",
15: "horse_bell", 16: "wedding"}
const MOTION_NORMAL_ATTACK := 13 # CRaceMotionData::NAME_NORMAL_ATTACKCOMBO_ATTACK_1 = 14
# 受击动作步 -> playersettingmodule RegisterMotionData 的文件(同名多份按权重随机,这里等权)
const REACTION_FILES := {
"damage": ["damage", "damage_1"], # NAME_DAMAGE
"damage_back": ["damage_2", "damage_3"], # NAME_DAMAGE_BACK
"knockdown": ["damage_flying"], # NAME_DAMAGE_FLYING
"knockdown_back": ["back_damage_flying"], # NAME_DAMAGE_FLYING_BACK
"standup": ["falling_stand"], # NAME_STAND_UP
"standup_back": ["back_falling_stand"], # NAME_STAND_UP_BACK
}
# CActorInstance::__SetMotion 尾:动作绑定完成(net_play 据此清 m_HitDataMap / 连击段号)。
signal motion_bound(state: String)
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
var motion_dir := ""
var action_dir := ""
var _state := ""
var _move_speed_ratio := 1.0
var _assets_root := ""
var _audio: Node
var _sound_instances: Array = []
var _sound_frame := -1
var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"]
var _left_bone := ""
var _race := 0
var _hit := HitReaction.new()
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
func build(assets_root: String, race: int, pump := Callable()) -> bool:
_assets_root = assets_root
_race = race
if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"):
return false
var cls: String = CLASS_OF[race & 3]
@@ -45,6 +76,12 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
# PC GR2 surfaces need the same outward winding in bind, CPU and GPU poses.
model.set("flip_winding", true)
model.set("texture_dir", base)
# 挂点骨骼名要在 gr2_path 之前设:Metin2Model 默认 equip_right_hand 只有战士骨架有,
# 其余职业找不到骨骼就不建 Weapon 节点(武器不在手上)。
model.set("weapon_bone", WEAPON_BONE[race & 3])
_left_bone = WEAPON_LEFT_BONE[race & 3]
if _left_bone != "":
model.set("shield_bone", _left_bone)
if pump.is_valid(): pump.call()
var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)])
if body == "":
@@ -88,14 +125,40 @@ func set_audio(audio_node: Node) -> void:
func set_anim_state(s: String) -> void:
if s == _state or anim == null or motion_dir == "":
return
if HitReaction.is_reaction(s):
_hit.start([[s]], _state, _bind_reaction)
return
# 受击动作链播放中:循环动作请求留到链尾 PushLoopMotion 再回(seam:参考端动作队列)
if _hit.active and s in HitReaction.LOOP_STATES:
_hit.resume = s
return
_hit.cancel()
_state = s
var msa := motion_dir.path_join(s + ".msa")
if not FileAccess.file_exists(msa):
msa = motion_dir.path_join(s + ".gr2")
if FileAccess.file_exists(msa):
anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", msa)
_refresh_sound_script(msa)
_bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
# CActorInstance::SetMoveSpeedm_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
# Move -> SetLoopMotion(WALK/RUN, 0.15, m_fMovSpd))。>1100 时参考端不走动作累计,
# EntityStore 退回按 duration 插值,这里保持原速播放。
func set_move_speed(moving_speed: int) -> void:
_move_speed_ratio = 1.0 if moving_speed <= 0 or moving_speed > 1100 \
else float(moving_speed) / 100.0
if anim != null and _state in MOVE_STATES and not _hit.active:
anim.set("time_scale", _hit.scale_for(_move_speed_ratio))
func _loop_speed(state: String) -> float:
return _move_speed_ratio if state in MOVE_STATES else 1.0
## 走 / 跑动作的根运动速度(cm/smovSpd 100):x = walky = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
if motion_dir == "":
return Vector2.ZERO
return Vector2(MsaMotion.move_speed(motion_dir.path_join("walk.msa")),
MsaMotion.move_speed(motion_dir.path_join("run.msa")))
# Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends
# the numeric motion id; paired emotions use the other entity's race to select
@@ -163,29 +226,97 @@ func set_motion_id(motion: int, target_race: int = -1) -> bool:
msa = motion_dir.path_join(name + ".msa")
if not FileAccess.file_exists(msa):
return false
_state = "__motion"
anim.set("loop", false)
# A repeated command can name the same clip; clear first because the native
# player treats assigning the current anim_path as a no-op.
if String(anim.get("anim_path")) == msa:
anim.set("anim_path", "")
anim.set("anim_path", msa)
_refresh_sound_script(msa)
_bind("__motion", msa, false, 1.0)
return true
# §3.7 CActorInstance::__SetMotion(SSetMotionData{ MAKE_MOTION_KEY(mode, index), fSpeedRatio })
# 攻击段动作按武器动作模式目录绑定(NORMAL_ATTACK -> attack(_1).msaCOMBO_ATTACK_N -> combo_0N.msa),
# 模式目录缺该段时退回 general 的 attack(_1).msaGENERAL 模式的 COMBO_ATTACK_* 也注册这两份)。
func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void:
if anim == null or motion_dir == "":
return
var mode_dir := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(mode, "general")))
var names: Array = ["attack", "attack_1"]
if index > MOTION_NORMAL_ATTACK:
names = ["combo_%02d" % (index - MOTION_NORMAL_ATTACK)]
var msa := _pick_msa(mode_dir, names)
if msa == "":
msa = _pick_msa(motion_dir, ["attack", "attack_1"])
if msa == "":
return
_bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0)
# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。
func insert_delay(d: float) -> void:
_hit.insert_delay(d, anim)
# CActorInstance::__HitGood / __HitGreate / __HitStoneActorInstanceBattle.cpp)。
# scalar = dot(攻击方朝向, 受击方朝向)< 0 面对面 -> 正面受击动作。
func hit_good(scalar: float, stunned: bool) -> void:
_hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction)
func hit_greate(scalar: float, stunned: bool) -> void:
_hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned)
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
# 受击方 m_DefendingPointInstanceList 的来源:root/msm/<class>_<m|w>.msm 的 DEFENDING 球。
func get_defending_spheres() -> Array:
var sex := "w" if _race in FEMALE_RACES else "m"
return HitCollision.parse_msm_defending(
_assets_root.path_join("root/msm/%s_%s.msm" % [CLASS_OF[_race & 3], sex]))
func _on_playback_finished() -> void:
# CLIENT-GAP §3.7: the hit reaction ("damage") is a one-shot knockback clip.
# When it ends the actor leaves the pushed state (CActorInstance::IsPushing),
# so fall back to idle — net_play polls is_in_hit_reaction() to drop its gate.
if _state == "__motion" or _state == "damage":
# §3.7 受击动作链:InterceptOnceMotion -> PushOnceMotion(STAND_UP…) -> PushLoopMotion。
# 链尾回循环动作 —— net_play polls is_in_hit_reaction() to drop its knockback gate.
if _hit.active:
if _hit.advance(_bind_reaction) or _hit.real_dead:
return
_state = ""
set_anim_state(_hit.resume)
return
if _state == "__motion":
_state = ""
set_anim_state("wait")
# True while the one-shot hit/knockback clip is still playing.
# True while the one-shot hit/knockback chain is still playing.
func is_in_hit_reaction() -> bool:
return _state == "damage"
return _hit.active
func _process(_delta: float) -> void:
func _bind_reaction(step: String) -> bool:
if anim == null or motion_dir == "":
return false
var msa := _pick_msa(motion_dir, REACTION_FILES.get(step, []))
if msa == "":
return false
_bind(step, msa, false, 1.0)
return true
func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not HitReaction.is_reaction(state):
_hit.cancel()
_state = state
anim.set("loop", loop)
# A repeated one-shot can name the same clip; clear first because the native
# player treats assigning the current anim_path as a no-op.
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "")
anim.set("anim_path", path)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
motion_bound.emit(state)
func _pick_msa(dir: String, names: Array) -> String:
var found: Array = []
for n in names:
var p := dir.path_join(String(n) + ".msa")
if FileAccess.file_exists(p):
found.append(p)
return "" if found.is_empty() else String(found[randi() % found.size()])
func _process(delta: float) -> void:
_hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
or not bool(anim.get("playing")):
return
@@ -207,6 +338,9 @@ func _refresh_sound_script(motion_path: String) -> void:
func _set(prop: StringName, val: Variant) -> bool:
if String(prop) in _forward and model:
# ActorInstanceAttach.cpp AttachWeapon:该职业没登记 PART_WEAPON_LEFT 骨骼就不挂左手
if String(prop) == "shield_gr2" and _left_bone == "":
val = ""
model.set(prop, val)
if String(prop) == "gr2_path":
call_deferred("_ground_model")
+15 -1
View File
@@ -47,6 +47,7 @@ var _slots := [] # 当前页 UI[{btn, cd, lbl, icon, grade}]
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
var _skill_cooldowns := {} # skill identity survives slot moves/restores within this UI session
var _page := 0
var _last_activation_reserved := false # 最近一次激活落到 __ReserveUseSkill(射程外)
var _move_from := -1
var _mobile_mode := false
@@ -194,6 +195,17 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
elif not PlayerSkill.is_silent_code(fishing_result):
skill_rejected.emit(int(s.id), fishing_result)
return
if code == "RESERVED":
# __ProcessEnemySkillTargetRange:射程外不发施法,蓄力技未预约时先
# __SendUseSkill(slot, 0),再 __ReserveUseSkill 交给 NetPlay 趋近。
_last_activation_reserved = true
if net_play and net_play.has_method("reserve_use_skill"):
var already: bool = net_play.has_method("is_use_skill_reserved") \
and bool(net_play.is_use_skill_reserved(global))
if bool(gate.get("charge", false)) and not already and client.has_method("use_skill"):
client.use_skill(int(s.id), 0)
net_play.reserve_use_skill(int(gate.get("target_vid", 0)), global, float(gate.get("range_cm", 0)))
return
if code == "TOGGLE_OFF":
if client.has_method("use_skill"):
client.use_skill(int(s.id), 0)
@@ -284,8 +296,10 @@ func activate_reserved(global_slot: int) -> bool:
var s: Dictionary = _state[global_slot]
if s.kind != "skill" or s.id == 0 or _now() < s.cd_end:
return false
_last_activation_reserved = false
activate(local)
return true
# 再次落到射程外时新预约已建立,不能让 NetPlay 把它当作已消费清掉。
return not _last_activation_reserved
# client.get_skills() 里该技能的当前等级(quickbar 施法时算 target_count 用)。
func _skill_level(sid: int) -> int:
+61 -2
View File
@@ -162,6 +162,60 @@ func load_file(path: String) -> bool:
count = _by_id.size()
return count > 0
# RegisterSkillTablePythonSkill.cpp:90):SkillTable.txt 按 TAB 切分,列数不等于
# TABLE_TOKEN_TYPE_MAX_NUM 的行、skilldesc 里没有的 vnum 都跳过。参考端 RegisterSkill(.msk)
# 没有调用点,TSkillData 构造时 dwTargetRange = 0,只由这里非空的 TARGET_RANGE 列覆盖;
# 所以表生效后不再沿用 .msk 的 Range。第 2 列是 CP949 技能名,按字节切分避免解码告警。
const TABLE_TOKEN_TYPE_VNUM := 0
const TABLE_TOKEN_TYPE_TARGET_RANGE := 25
const TABLE_TOKEN_TYPE_MAX_NUM := 27
func load_table(path: String) -> bool:
if _by_id.is_empty() or not FileAccess.file_exists(path):
return false
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return false
var bytes := file.get_buffer(file.get_length())
var ranges := {}
var start := 0
while start < bytes.size():
var stop := bytes.find(10, start)
if stop < 0:
stop = bytes.size()
var fields := _tab_fields(bytes.slice(start, stop))
start = stop + 1
if fields.size() != TABLE_TOKEN_TYPE_MAX_NUM:
continue
var vnum := int(_ascii_token(fields[TABLE_TOKEN_TYPE_VNUM]))
if not _by_id.has(vnum):
continue
ranges[vnum] = _ascii_token(fields[TABLE_TOKEN_TYPE_TARGET_RANGE])
if ranges.is_empty():
return false
for id in _by_id:
_by_id[id]["target_range"] = 0
for vnum in ranges:
if String(ranges[vnum]) != "":
_by_id[vnum]["target_range"] = int(ranges[vnum])
return true
static func _tab_fields(line: PackedByteArray) -> Array:
var fields := []
var begin := 0
for i in line.size() + 1:
if i == line.size() or line[i] == 9:
fields.append(line.slice(begin, i))
begin = i + 1
return fields
static func _ascii_token(field: PackedByteArray) -> String:
var out := PackedByteArray()
for b in field:
if b >= 32 and b <= 126:
out.append(b)
return out.get_string_from_ascii().strip_edges()
func has(id: int) -> bool:
return _by_id.has(id)
@@ -270,9 +324,14 @@ func target_count(id: int, level: int) -> int:
return int(floor(float(value)))
# __GetSkillTargetRangePythonPlayerSkill.cpp:391= rkSkillData.GetTargetRange()
# + GetStatus(POINT_BOW_DISTANCE)*100。这里只给 .msk 的基础射程(cm);主角弓距加成
# 由 net_play(有玩家状态)叠加。
# + GetStatus(POINT_BOW_DISTANCE)*100。这里是 GetTargetRangePythonSkill.cpp:1086):
# MELEE_ATTACK / CHARGE_ATTACK 固定 MELEE_SKILL_TARGET_RANGE,否则 dwTargetRange
# load_table 之后取 SkillTable.txt TARGET_RANGE);主角弓距加成由 player_skill 叠加。
const MELEE_SKILL_TARGET_RANGE := 170
func target_range(id: int) -> int:
if is_melee(id) or is_charge_skill(id):
return MELEE_SKILL_TARGET_RANGE
return int(_by_id.get(id, {}).get("target_range", 0))
# skilldesc / .msk 的 FAN_RANGE / CIRCLE_RANGE → NetPlay.FlyShapeSINGLE/FAN/CIRCLE)。
+5 -7
View File
@@ -177,9 +177,8 @@ func close_top() -> bool:
func top() -> Control:
return _stack[-1] if not _stack.is_empty() else null
# wndMgr 的输入边界:打开的窗口先拿到键盘 / 鼠标语义,世界控制不能从窗口
# 下方穿透。非模态窗口保留原版常用的开关键,便于 I/K/V/N 等键关闭当前窗;
# 模态确认框只允许 ESC 回到窗口栈。
# wndMgr 的输入边界:鼠标不能从打开的窗口下方穿透到世界;模态确认框(LockWindow)
# 只允许 ESC 回到窗口栈。键盘按 CWindowManager::RunKeyDown:非模态窗口不截键。
func blocks_game_input(event: InputEvent) -> bool:
var popup := _mobile_modal_popup()
if popup != null:
@@ -197,10 +196,9 @@ func blocks_game_input(event: InputEvent) -> bool:
return true
if event is InputEventMouse:
return top_window.get_global_rect().has_point(event.position)
if event is InputEventKey:
if event.keycode == KEY_ESCAPE:
return false
return event.keycode not in [KEY_I, KEY_K, KEY_V, KEY_N, KEY_B, KEY_L, KEY_M, KEY_O]
# 键盘:没有 LockWindow 时键先给 ActiveWindow(输入框——Godot 里由获得焦点的控件在
# gui_input 中自行吃掉),其余落到 game.py OnKeyDown,所以技能窗 / 背包开着时
# 快捷栏 1-4 / F1-F4、Space 普攻和开关窗键都照常生效。
return false
func _input(event: InputEvent) -> void:
+81
View File
@@ -0,0 +1,81 @@
# weapon_attach_test —— 武器挂点按种族 + 左右手(真 PlayerView + 真 gr2)。
# godot --headless --path project --script weapon_attach_test.gd
#
# 参考端 playersettingmodule.py RegisterAttachingBoneName
# warrior PART_WEAPON=equip_right_hand (661)
# assassin PART_WEAPON=equip_right PART_WEAPON_LEFT=equip_left (873-874)
# sura PART_WEAPON=equip_right (999)
# shaman PART_WEAPON=equip_right PART_WEAPON_LEFT=equip_left (1190-1191)
# ActorInstanceAttach.cpp AttachWeaponGetAttachingBoneName 失败(该职业没登记左手)就不挂。
# PlayerView 的 shield_gr2 槽 = PART_WEAPON_LEFT(参考端没有盾牌挂件)。
extends SceneTree
const PlayerView = preload("res://ui/player_view.gd")
const RIGHT_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"]
const LEFT_BONE := ["", "equip_left", "", "equip_left"]
const MAX_GRIP_M := 0.10
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: weapon_attach_test (race attaching bones + left/right hand)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
if not ClassDB.class_exists("Metin2Model"):
print(" (skip: no Metin2Model extension)")
return
var assets := AssetRoot.path()
var weapon_dir := assets.path_join("item/ymir work/item/weapon")
var sword := weapon_dir.path_join("00010.gr2")
var dagger := weapon_dir.path_join("04000.gr2")
if not FileAccess.file_exists(sword) or not FileAccess.file_exists(dagger):
print(" (skip: weapon gr2 assets missing)")
return
await process_frame
for race in 8:
var job := race & 3
var pv = PlayerView.new()
get_root().add_child(pv)
if not pv.build(assets, race):
print(" (skip race %d: body assets missing)" % race)
pv.free()
continue
pv.set("weapon_gr2", sword)
pv.set("shield_gr2", dagger)
for i in 3:
await process_frame
var right := pv.model.get_node_or_null("Weapon") as Node3D
_ck(right != null, "race %d: right-hand weapon attached (%s)" % [race, RIGHT_BONE[job]])
if right:
_ck(_near(pv, right, RIGHT_BONE[job]), "race %d: weapon sits on %s" % [race, RIGHT_BONE[job]])
var left := pv.model.get_node_or_null("Shield") as Node3D
if LEFT_BONE[job] == "":
_ck(left == null, "race %d: no PART_WEAPON_LEFT bone registered -> nothing on left hand" % race)
else:
_ck(left != null, "race %d: left-hand weapon attached (%s)" % [race, LEFT_BONE[job]])
if left:
_ck(_near(pv, left, LEFT_BONE[job]), "race %d: left weapon sits on %s" % [race, LEFT_BONE[job]])
pv.queue_free()
await process_frame
func _near(pv: Node, node: Node3D, bone: String) -> bool:
var pose: Dictionary = pv.anim.call("get_effect_bone_pose", bone)
if pose.is_empty():
printerr(" bone %s has no pose" % bone)
return false
var hand: Vector3 = (pv.model as Node3D).global_transform * (pose["transform"] as Transform3D).origin
var d := node.global_position.distance_to(hand)
if d > MAX_GRIP_M:
printerr(" %s is %.3f m from %s" % [node.name, d, bone])
return d <= MAX_GRIP_M
+410 -95
View File
@@ -1,130 +1,445 @@
#!/usr/bin/env node
/* Static prerequisite audit for the first playable-map fixture. It does not
* connect to a server and cannot prove which monsters a live server spawned. */
/* Static prerequisite audit for the first playable-map fixture (FIRST-MAC-PLAYABLE §8.1).
* It reads the real map setting, TextureSet, AreaData/Property, npclist, MSM and motlist,
* resolves every referenced file the way the runtime does, and lists missing items.
* It does not connect to a server and cannot prove which monsters a live server spawned:
* tree monsters are reported as candidates only.
*
* exit: 0 = no unexplained gaps, 1 = missing resources, 2 = config/precondition blocked. */
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const USAGE = `usage: audit_playable_maps.mjs [--config SCENARIO.json | --maps DIR[,DIR...]] [--assets DIR] [--races N[,N...]] [--output FILE]
--config scenario JSON; its map_key is audited (credentials are never read from it)
--maps explicit map directories relative to the asset root
--assets asset root (default: assets)
--races candidate monster races (default: npclist entries 2301-2315)
--output report path (default: build/playable/map-assets.json)`;
const DEFAULT_MAPS = ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'];
const PROPERTY_EXTENSIONS = ['.prb', '.prt', '.pre', '.prd', '.pra'];
// Keep in sync with project/ui/mob_view.gd STATE_MOTIONS (first name = primary motion).
const STATE_MOTIONS = {
wait: ['WAIT', 'WAIT1'],
run: ['RUN', 'WALK'],
attack: ['NORMAL_ATTACK', 'NORMAL_ATTACK1', 'SPECIAL_1'],
damage: ['FRONT_DAMAGE', 'DAMAGE', 'BACK_DAMAGE'],
dead: ['FRONT_DEAD', 'DEAD', 'BACK_DEAD'],
};
// Reference CActorInstance::Move -> SetLoopMotion(RUN/WALK): a GetMotionKey miss returns early
// and the current loop keeps playing. Only these states may be explained by that rule.
const REFERENCE_KEEP_CURRENT_STATES = ['run'];
const TILE_FILES = ['areadata.txt', 'height.raw', 'tile.raw', 'attr.atr'];
class Blocked extends Error {}
function parseArgs(argv) {
const out = { assets: 'assets', output: '', maps: ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'] };
const out = { assets: 'assets', output: 'build/playable/map-assets.json', maps: '', config: '', races: '' };
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--help') { console.log('usage: audit_playable_maps.mjs [--assets DIR] [--maps DIR[,DIR...]] [--output FILE]'); process.exit(0); }
if (!a.startsWith('--') || i + 1 >= argv.length) throw new Error(`invalid argument: ${a}`);
if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
if (!a.startsWith('--') || i + 1 >= argv.length) throw new Blocked(`invalid argument: ${a}`);
const k = a.slice(2).replaceAll('-', '_');
if (!(k in out)) throw new Blocked(`unknown option: ${a}`);
out[k] = argv[++i];
}
if (typeof out.maps === 'string') out.maps = out.maps.split(',').map(s => s.trim()).filter(Boolean);
if (out.config && out.maps) throw new Blocked('use either --config or --maps, not both');
return out;
}
function filesUnder(root) {
if (!fs.existsSync(root)) return [];
const result = [];
const visit = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) visit(full); else result.push(full);
}
};
visit(root);
return result;
}
const norm = value => value.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/');
const read = file => fs.readFileSync(file, 'utf8');
const posix = value => value.replaceAll('\\', '/');
const read = file => fs.readFileSync(file, 'latin1');
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
function parseProperties(allFiles) {
const byId = new Map();
for (const file of allFiles.filter(f => f.toLowerCase().endsWith('.prt'))) {
const lines = read(file).split(/\r?\n/);
const id = lines[1]?.trim();
if (!/^\d+$/.test(id || '')) continue;
const text = lines.join('\n');
const tree = text.match(/^\s*treefile\s+"([^"]+)"/im)?.[1] || '';
const type = text.match(/^\s*propertytype\s+"([^"]+)"/im)?.[1] || '';
byId.set(id, { id: Number(id), file, type, treefile: tree });
// Mirrors fmt::AssetResolver: files are indexed by their asset-root relative path and by the
// suffix after "ymir work/", with the pack priority deciding which real file wins.
class AssetIndex {
constructor(root) {
this.root = root;
this.byRel = new Map();
this.byYmir = new Map();
const priority = ['zone', 'terrain', 'etc', 'pc', 'tree', 'property', 'effect', 'monster', 'npc', 'season2', 'season3_eu'];
const rank = rel => {
const top = rel.split('/')[0].toLowerCase();
const i = priority.indexOf(top);
if (i >= 0) return i;
return top.startsWith('metin2_patch') ? 2000 : 1000;
};
const files = [];
const visit = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) visit(full); else files.push(posix(path.relative(root, full)));
}
};
if (fs.existsSync(root)) visit(root);
files.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
for (const rel of files) {
const lower = rel.toLowerCase();
if (!this.byRel.has(lower)) this.byRel.set(lower, rel);
const at = lower.indexOf('ymir work/');
if (at >= 0) {
const key = lower.slice(at + 'ymir work/'.length);
if (!this.byYmir.has(key)) this.byYmir.set(key, rel);
}
}
this.files = files;
this.dirs = new Set();
for (const rel of files) {
const parts = rel.toLowerCase().split('/');
for (let i = 1; i < parts.length; i += 1) this.dirs.add(parts.slice(0, i).join('/'));
}
this.topDirs = [...new Set(files.filter(rel => rel.includes('/')).map(rel => rel.split('/')[0]))].sort();
}
return byId;
hasDir(relDir) { return this.dirs.has(AssetIndex.normalize(relDir)); }
static normalize(virtualPath) {
let s = posix(String(virtualPath || '')).toLowerCase();
if (/^[a-z]:/.test(s)) s = s.slice(2);
return s.replace(/^\/+/, '').replace(/\/{2,}/g, '/');
}
// Returns the asset-root relative real path, or ''.
resolve(virtualPath) {
const s = AssetIndex.normalize(virtualPath);
if (!s) return '';
const at = s.indexOf('ymir work/');
if (at >= 0) return this.byYmir.get(s.slice(at + 'ymir work/'.length)) || '';
return this.byRel.get(s) || '';
}
rel(relPath) { return this.byRel.get(AssetIndex.normalize(relPath)) || ''; }
abs(rel) { return path.join(this.root, rel); }
}
function parseArea(file) {
const text = read(file);
function parseKeyValues(text) {
const out = {};
for (const line of text.split(/\r?\n/)) {
const m = line.trim().match(/^([A-Za-z_][\w]*)\s+(.*)$/);
if (m && !(m[1].toLowerCase() in out)) out[m[1].toLowerCase()] = m[2].trim();
}
return out;
}
// Property file: line 1 "YPRT", line 2 CRC, then key "value" pairs (formats/property.h).
function parseProperty(index, rel) {
const lines = read(index.abs(rel)).split(/\r?\n/);
const crc = lines[1]?.trim();
if (!/^\d+$/.test(crc || '')) return null;
const kv = {};
for (const line of lines.slice(2)) {
const m = line.trim().match(/^(\S+)\s+"([^"]*)"/);
if (m) kv[m[1].toLowerCase()] = m[2];
}
return { crc: Number(crc), file: rel, type: kv.propertytype || '', name: kv.propertyname || '', kv };
}
function propertyRegistries(index) {
const runtime = new Map();
const outside = new Map();
let collisions = 0;
for (const rel of index.files) {
if (!PROPERTY_EXTENSIONS.includes(path.extname(rel).toLowerCase())) continue;
const prop = parseProperty(index, rel);
if (!prop) continue;
// PropertyRegistry::scan_list only accepts the top-level Property/ directory.
const target = rel.toLowerCase().startsWith('property/') ? runtime : outside;
if (target.has(prop.crc)) { if (target === runtime) collisions += 1; continue; }
target.set(prop.crc, prop);
}
return { runtime, outside, collisions };
}
function parseObjects(text) {
const objects = [];
const blockRe = /Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g;
for (const match of text.matchAll(blockRe)) {
for (const match of text.matchAll(/Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g)) {
const lines = match[1].split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (lines.length < 2) continue;
const id = Number(lines[1]);
if (Number.isInteger(id)) objects.push({ property_id: id, position: lines[0] });
if (lines.length < 2 || !/^\d+$/.test(lines[1])) continue;
objects.push({ crc: Number(lines[1]), position: lines[0] });
}
return objects;
}
function parseNpcList(file) {
const result = new Map();
if (!fs.existsSync(file)) return result;
for (const line of read(file).split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]);
function parseTextureSet(text) {
const entries = [];
for (const match of text.matchAll(/Start Texture(\d+)\r?\n([^]*?)\r?\nEnd Texture/g)) {
const file = match[2].match(/"([^"]*)"/)?.[1] ?? '';
entries.push({ index: Number(match[1]), file });
}
return result;
return entries;
}
function mobRecord(assets, code, race, allFiles) {
const candidates = allFiles.filter(file => file.toLowerCase().endsWith('.msm') && path.basename(file, '.msm').toLowerCase() === code.toLowerCase())
.filter(file => file.toLowerCase().includes(`/monster2/`) || file.toLowerCase().includes(`/monster/`));
const msm = candidates[0] || '';
const dir = msm ? path.dirname(msm) : '';
const sibling = name => dir && fs.existsSync(path.join(dir, name)) ? path.join(dir, name) : '';
const motlist = sibling('motlist.txt');
const actions = motlist ? [...read(motlist).matchAll(/^\s*\S+\s+([A-Z0-9_]+)\s+([^\s]+\.msa)/gmi)].map(m => ({ name: m[1], file: m[2], exists: fs.existsSync(path.join(dir, m[2])) })) : [];
return {
race, code, directory: dir ? path.relative(assets, dir).replaceAll('\\', '/') : '',
files: { msm: Boolean(msm), gr2: Boolean(sibling(`${code}.gr2`)), dds: Boolean(sibling(`${code}.dds`)), motlist: Boolean(motlist) },
action_count: actions.length, actions,
required_states: Object.fromEntries(['WAIT', 'RUN', 'NORMAL_ATTACK', 'FRONT_DAMAGE', 'FRONT_DEAD'].map(state => [state, actions.some(a => a.name === state)])),
source_sha256: msm ? sha256(msm) : null,
};
function parseNpcList(index) {
const result = new Map();
for (const candidate of ['root/npclist.txt', 'npclist.txt', 'locale/npclist.txt']) {
const rel = index.rel(candidate);
if (!rel) continue;
for (const line of read(index.abs(rel)).split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]);
}
return { file: rel, entries: result };
}
return { file: '', entries: result };
}
function auditMap(index, registries, key, source, missing) {
const note = (kind, ref, reason, extra = {}) => missing.push({ scope: `map:${key}`, kind, ref, reason, ...extra });
const rootRel = index.rel(path.posix.join(key, 'setting.txt'));
const map = { key, source, exists: false, setting: '', setting_values: {}, tiles: [], texture_set: null,
environment: null, regen: null, object_count: 0, static_spt: [], static_gr2: [], effects: [], ambience: [],
unresolved_properties: [], property_count: 0 };
if (!rootRel) {
note('map_setting', `${key}/Setting.txt`, 'map directory or Setting.txt not found under the asset root');
return map;
}
map.exists = true;
map.setting = rootRel;
const dirRel = path.posix.dirname(rootRel);
const setting = parseKeyValues(read(index.abs(rootRel)));
const [sizeX, sizeY] = (setting.mapsize || '').split(/\s+/).map(Number);
map.setting_values = { map_size: [sizeX || 0, sizeY || 0], base_position: (setting.baseposition || '').split(/\s+/).map(Number),
cell_scale: Number(setting.cellscale || 0), texture_set: setting.textureset || '', environment: setting.environment || '' };
if (!(sizeX > 0 && sizeY > 0)) note('map_setting', rootRel, 'MapSize is missing or invalid');
// Tiles: the runtime loads <tx*1000+ty padded to 6>/ for every MapSize cell (m2coord::tile_dir).
const objects = [];
const ambienceObjects = [];
for (let tx = 0; tx < (sizeX || 0); tx += 1) {
for (let ty = 0; ty < (sizeY || 0); ty += 1) {
const tile = String(tx * 1000 + ty).padStart(6, '0');
const files = Object.fromEntries(TILE_FILES.map(name => [name, Boolean(index.rel(`${dirRel}/${tile}/${name}`))]));
map.tiles.push({ tile, files });
for (const [name, present] of Object.entries(files)) if (!present) note('tile_file', `${dirRel}/${tile}/${name}`, 'tile file missing');
const area = index.rel(`${dirRel}/${tile}/areadata.txt`);
if (area) objects.push(...parseObjects(read(index.abs(area))).map(o => ({ ...o, tile })));
const ambience = index.rel(`${dirRel}/${tile}/areaambiencedata.txt`);
if (ambience) ambienceObjects.push(...parseObjects(read(index.abs(ambience))).map(o => ({ ...o, tile })));
}
}
map.object_count = objects.length;
map.ambience_object_count = ambienceObjects.length;
// TextureSet: runtime reads <assets>/textureset/<TextureSet lowercased>.
if (setting.textureset) {
const tsRel = index.rel(`textureset/${posix(setting.textureset).toLowerCase()}`);
map.texture_set = { ref: setting.textureset, file: tsRel, textures: [] };
if (!tsRel) note('texture_set', setting.textureset, 'TextureSet file not found under textureset/');
else {
map.texture_set.textures = parseTextureSet(read(index.abs(tsRel))).map(entry => {
const real = entry.file ? index.resolve(entry.file) : '';
if (entry.file && !real) note('terrain_texture', entry.file, 'TextureSet entry does not resolve');
return { ...entry, resolved: real };
});
if (map.texture_set.textures.length === 0) note('texture_set', tsRel, 'TextureSet has no texture entries');
}
} else note('texture_set', rootRel, 'Setting.txt has no TextureSet');
if (setting.environment) {
const real = index.resolve(`d:/ymir work/environment/${setting.environment}`);
map.environment = { ref: setting.environment, resolved: real };
if (!real) note('environment', setting.environment, 'environment .msenv does not resolve');
}
const regenRel = index.rel(`${dirRel}/regen.txt`);
const regenEntries = regenRel ? read(index.abs(regenRel)).split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('//')).length : 0;
map.regen = { file: regenRel, entries: regenEntries,
note: regenEntries === 0 ? 'no client-side spawn data; server spawn membership is unknown' : 'client regen.txt is not proof of live server spawns' };
const manifestRel = index.rel('TreeGeometry/manifest.json');
const manifest = manifestRel ? JSON.parse(fs.readFileSync(index.abs(manifestRel), 'utf8')) : { trees: {} };
const nativeBySource = new Map(Object.values(manifest.trees || {}).map(item => [index.resolve(item.source) || AssetIndex.normalize(item.source), item]));
const groups = new Map();
const counts = new Map();
for (const o of [...objects, ...ambienceObjects]) counts.set(o.crc, (counts.get(o.crc) || 0) + 1);
map.property_count = counts.size;
for (const [crc, instances] of counts) {
const prop = registries.runtime.get(crc);
if (!prop) {
const elsewhere = registries.outside.get(crc);
const item = { property_id: crc, instances, found_outside_runtime_scope: elsewhere ? elsewhere.file : '' };
map.unresolved_properties.push(item);
note('property', String(crc), elsewhere ? 'CRC only exists outside Property/, which the runtime registry does not scan'
: 'CRC not found in any property file', { instances });
continue;
}
const base = { property_id: crc, instances, property_file: prop.file, property_type: prop.type, name: prop.name };
const type = prop.type.toLowerCase();
if (type === 'tree') {
const ref = prop.kv.treefile || '';
const spt = ref ? index.resolve(ref) : '';
const native = spt ? nativeBySource.get(spt) : undefined;
const glb = native ? index.rel(`TreeGeometry/${native.glb}`) : '';
const item = { ...base, treefile: ref, spt, native_glb: glb, source_sha256: native?.source_sha256 || null };
map.static_spt.push(item);
if (!spt) note('tree_spt', ref || prop.file, 'treefile does not resolve', { instances });
else if (!glb) note('tree_native_geometry', spt, 'no extracted TreeGeometry entry for this SPT', { instances });
} else if (type === 'building' || type === 'dungeonblock') {
const ref = prop.kv[type === 'building' ? 'buildingfile' : 'dungeonblockfile'] || '';
const gr2 = ref ? index.resolve(ref) : '';
map.static_gr2.push({ ...base, model: ref, gr2, sha256: gr2 ? sha256(index.abs(gr2)) : null });
if (!gr2) note('building_gr2', ref || prop.file, 'model file does not resolve', { instances });
} else if (type === 'effect') {
const ref = prop.kv.effectfile || '';
const mse = ref ? index.resolve(ref) : '';
// Metin2World skips Effect objects for static rendering; still audit the file.
map.effects.push({ ...base, effectfile: ref, mse, rendered_by_world_loader: false });
if (!mse) note('effect_mse', ref || prop.file, 'effect file does not resolve', { instances });
} else if (type === 'ambience') {
const refs = (prop.kv.ambiencesoundvector || '').split(/[,;]/).map(s => s.trim()).filter(Boolean);
const sounds = refs.map(ref => ({ ref, resolved: index.resolve(ref) || index.rel(ref) || index.rel(`sound/${ref}`) }));
map.ambience.push({ ...base, sounds });
for (const s of sounds) if (!s.resolved) note('ambience_sound', s.ref, 'ambience sound does not resolve', { instances });
} else {
map.unresolved_properties.push({ ...base, unsupported_type: true });
note('property_type', prop.file, `unsupported property type "${prop.type}"`, { instances });
}
}
map.static_spt_instances = map.static_spt.reduce((n, item) => n + item.instances, 0);
map.static_gr2_instances = map.static_gr2.reduce((n, item) => n + item.instances, 0);
return map;
}
// Mirrors MobView._find_dir + the npclist stem segment fallback (bear_brown -> bear).
function findMobDir(index, code) {
const segs = code.split('_');
for (let cut = segs.length; cut > 0; cut -= 1) {
const folder = segs.slice(0, cut).join('_');
const rels = ['ymir work/monster', 'ymir work/monster2', 'ymir work/npc', 'ymir work/npc2',
'Monster/ymir work/monster', 'Monster/ymir work/monster2', 'NPC/ymir work/npc', 'NPC/ymir work/npc2'].map(r => `${r}/${folder}`);
for (const rel of rels) if (index.hasDir(rel)) return rel;
for (const sub of index.topDirs) for (const rel of rels) if (index.hasDir(`${sub}/${rel}`)) return `${sub}/${rel}`;
}
return '';
}
function auditMob(index, race, code, missing, explained) {
const scope = `race:${race}`;
const note = (kind, ref, reason) => missing.push({ scope, kind, ref, reason });
const record = { race, code, candidate_only: true, directory: '', files: {}, msm: null, textures: [], motions: [], states: {} };
if (!code) { note('npclist', String(race), 'race is not listed in npclist'); return record; }
const dir = findMobDir(index, code);
record.directory = dir;
if (!dir) { note('mob_directory', code, 'no monster/npc directory for this code'); return record; }
const inDir = name => index.rel(`${dir}/${name}`);
const gr2 = inDir(`${code}.gr2`) || inDir(`${path.posix.basename(dir)}.gr2`);
const msmRel = inDir(`${code}.msm`);
record.files = { gr2, msm: msmRel, motlist: inDir('motlist.txt') };
record.source_sha256 = gr2 ? sha256(index.abs(gr2)) : null;
if (!gr2) note('mob_gr2', `${dir}/${code}.gr2`, 'base model GR2 missing (dynamic skinned model)');
if (msmRel) {
const base = read(index.abs(msmRel)).match(/BaseModelFileName\s+"([^"]+)"/i)?.[1] || '';
const resolved = base ? index.resolve(base) : '';
record.msm = { file: msmRel, base_model: base, resolved, matches_runtime_gr2: Boolean(resolved && gr2 && resolved.toLowerCase() === gr2.toLowerCase()) };
if (base && !resolved) note('msm_base_model', base, 'MSM BaseModelFileName does not resolve');
} else note('mob_msm', `${dir}/${code}.msm`, 'MSM missing');
const dirPrefix = `${dir.toLowerCase()}/`;
record.textures = index.files.filter(f => f.toLowerCase().startsWith(dirPrefix) && !f.slice(dirPrefix.length).includes('/') && f.toLowerCase().endsWith('.dds'));
if (record.textures.length === 0) note('mob_texture', dir, 'no .dds texture next to the model');
const motions = new Map();
if (record.files.motlist) {
for (const line of read(index.abs(record.files.motlist)).split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length < 3 || !parts[2].toLowerCase().endsWith('.msa')) continue;
const file = inDir(parts[2]);
record.motions.push({ group: parts[0], name: parts[1], file: parts[2], exists: Boolean(file), weight: Number(parts[3] || 0) });
if (!file) note('motion_file', `${dir}/${parts[2]}`, `motlist ${parts[1]} references a missing .msa`);
else if (!motions.has(parts[1])) motions.set(parts[1], file);
}
} else note('motlist', `${dir}/motlist.txt`, 'motlist missing');
for (const [state, names] of Object.entries(STATE_MOTIONS)) {
let entry = null;
for (const name of names) {
if (motions.has(name)) { entry = { motion: name, file: motions.get(name), fallback_reason: name === names[0] ? '' : `client_alias:${name}` }; break; }
}
if (!entry) {
for (const name of names) {
const file = inDir(`${name.toLowerCase()}.msa`);
if (file) { entry = { motion: name, file, fallback_reason: `motlist_missing:${path.posix.basename(file)}` }; break; }
}
}
if (!entry) {
entry = { motion: '', file: '', fallback_reason: 'reference_keep_current_motion' };
if (REFERENCE_KEEP_CURRENT_STATES.includes(state)) {
explained.push({ scope, kind: 'motion', ref: state, reason: `no ${names.join('/')} motion; reference SetLoopMotion keeps the current loop`,
needs_live_confirmation: 'a moving server instance of this race keeps its idle loop while its position changes' });
} else note('motion', `${code}:${state}`, `no ${names.join('/')} motion for a required state`);
}
record.states[state] = entry;
}
return record;
}
function mapsFromConfig(configPath) {
if (!fs.existsSync(configPath)) throw new Blocked(`config not found: ${configPath}`);
let config;
try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (error) { throw new Blocked(`config is not valid JSON: ${error.message}`); }
const mapKey = String(config.map_key || '').trim();
if (!mapKey) throw new Blocked('config map_key is empty; resolve it from the server/map fixture');
if (mapKey.startsWith('/') || mapKey.includes('\\') || mapKey.split('/').includes('..')) throw new Blocked('config map_key must be a relative asset directory');
return { maps: [mapKey], scenario_id: String(config.scenario_id || '') };
}
function main() {
const options = parseArgs(process.argv.slice(2));
const assets = path.resolve(options.assets);
const allFiles = filesUnder(assets);
const manifestPath = path.join(assets, 'TreeGeometry', 'manifest.json');
const manifest = fs.existsSync(manifestPath) ? JSON.parse(read(manifestPath)) : { trees: {} };
const sourceByName = new Map(Object.values(manifest.trees || {}).map(item => [norm(item.source), item]));
const properties = parseProperties(allFiles);
const maps = options.maps.map(key => {
const root = path.join(assets, key);
const areaFiles = filesUnder(root).filter(f => path.basename(f).toLowerCase() === 'areadata.txt');
const objects = areaFiles.flatMap(file => parseArea(file).map(item => ({ ...item, file: path.relative(assets, file).replaceAll('\\', '/') })));
const propertyIds = [...new Set(objects.map(item => item.property_id))];
const resolved = propertyIds.map(id => {
const prop = properties.get(String(id));
if (!prop) return { property_id: id, resolved: false };
const record = sourceByName.get(norm(prop.treefile));
return { property_id: id, resolved: true, property_type: prop.type, property_file: path.relative(assets, prop.file).replaceAll('\\', '/'),
treefile: prop.treefile, native_tree: Boolean(record), source_sha256: record?.source_sha256 || null };
});
const treeObjects = objects.filter(item => resolved.find(r => r.property_id === item.property_id)?.native_tree);
return { key, exists: fs.existsSync(root), setting: fs.existsSync(path.join(root, 'setting.txt')),
area_files: areaFiles.length, object_count: objects.length, property_count: propertyIds.length,
resolved_properties: resolved.length, unresolved_property_ids: resolved.filter(r => !r.resolved).map(r => r.property_id),
tree_object_count: treeObjects.length, native_tree_object_count: treeObjects.length,
properties: resolved };
});
const npc = parseNpcList(path.join(assets, 'root/npclist.txt'));
const treeMobRaces = [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.has(race));
const mobs = treeMobRaces.map(race => mobRecord(assets, npc.get(race), race, allFiles));
const report = { schema_version: 1, suite: 'playable-map-assets', assets_root: assets,
maps, tree_monster_candidates: mobs,
caveat: 'Static map AreaData and local resources only; server spawn membership and visual correctness require live/map-render tests.' };
if (options.output) { fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }); fs.writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`); }
console.log(JSON.stringify({ maps: maps.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count, native_tree_objects: m.native_tree_object_count, unresolved: m.unresolved_property_ids.length })), tree_monsters: mobs.length }));
if (maps.some(m => !m.exists || !m.setting || m.area_files === 0) || mobs.some(m => !m.files.msm || !m.files.gr2 || !m.files.dds || !m.files.motlist)) process.exitCode = 1;
if (!fs.existsSync(assets)) throw new Blocked(`asset root not found: ${assets}`);
let mapSource = 'default';
let maps = DEFAULT_MAPS;
let scenarioId = '';
if (options.config) {
const fromConfig = mapsFromConfig(options.config);
maps = fromConfig.maps;
scenarioId = fromConfig.scenario_id;
mapSource = 'config';
} else if (options.maps) {
maps = options.maps.split(',').map(s => s.trim()).filter(Boolean);
mapSource = 'argument';
}
const index = new AssetIndex(assets);
const registries = propertyRegistries(index);
const missing = [];
const explained = [];
const mapReports = maps.map(key => auditMap(index, registries, key, mapSource, missing));
const npc = parseNpcList(index);
if (!npc.file) missing.push({ scope: 'global', kind: 'npclist', ref: 'root/npclist.txt', reason: 'npclist not found' });
const races = options.races ? options.races.split(',').map(Number).filter(Number.isInteger)
: [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.entries.has(race));
if (races.length === 0) missing.push({ scope: 'global', kind: 'tree_monster_candidates', ref: '2301-2315', reason: 'no candidate races found' });
const mobs = races.map(race => auditMob(index, race, npc.entries.get(race) || '', missing, explained));
const report = {
schema_version: 2, suite: 'playable-map-assets', generated_at: new Date().toISOString(),
assets_root: assets, map_source: mapSource, scenario_id: scenarioId, npclist: npc.file,
property_registry: { runtime_properties: registries.runtime.size, outside_runtime_scope: registries.outside.size, crc_collisions: registries.collisions },
maps: mapReports, tree_monster_candidates: mobs,
missing, explained,
status: missing.length === 0 ? 'PASS' : 'FAIL',
caveat: 'Static map AreaData and local resources only; tree monsters are candidates, not proof of live server spawns. Visual correctness requires the render tests and manual sign-off.',
};
const output = path.resolve(options.output);
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
console.log(`MAP_ASSETS ${JSON.stringify({ status: report.status, output, maps: mapReports.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count,
spt: m.static_spt_instances || 0, gr2: m.static_gr2_instances || 0, unresolved: m.unresolved_properties.length })),
tree_monsters: mobs.length, missing: missing.length, explained: explained.length })}`);
for (const item of missing) console.error(`MISSING ${item.scope} ${item.kind} ${item.ref}: ${item.reason}`);
process.exitCode = missing.length === 0 ? 0 : 1;
}
main();
try {
main();
} catch (error) {
if (error instanceof Blocked) {
console.error(`MAP_ASSETS BLOCKED: ${error.message}`);
process.exitCode = 2;
} else {
console.error(`MAP_ASSETS ERROR: ${error.stack || error}`);
process.exitCode = 1;
}
}
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
/* MAP-01 audit regression on a throw-away asset tree. Proves that unresolved
* property CRCs, missing model/texture/motion files and an empty config map_key
* are reported (exit 1 / 2) instead of passing, and that a resource-level
* RUN/WALK gap is only "explained" by the reference keep-current-motion rule. */
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'audit_playable_maps.mjs');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-map-audit.'));
let failures = 0;
const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } };
const write = (rel, text) => { const file = path.join(root, rel); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text); };
const object = (i, crc) => `Start Object${String(i).padStart(3, '0')}\n 100.0 -100.0 0.0\n ${crc}\n 0.0#0.0#0.0\n 0\nEnd Object\n`;
const prop = (crc, type, key, value) => `YPRT\n${crc}\n${key}\t\t"${value}"\npropertyname\t\t"p${crc}"\npropertytype\t\t"${type}"\n`;
const motlist = names => names.map(([name, file]) => `GENERAL ${name} ${file} 50`).join('\n') + '\n';
function buildFixture({ broken }) {
fs.rmSync(root, { recursive: true, force: true });
write('mapa/setting.txt', 'ScriptType\tMapSetting\nCellScale\t200\nMapSize\t1\t1\nBasePosition\t0\t0\nTextureSet\ttextureset\\mapa.txt\nEnvironment\ta.msenv\n');
write('mapa/000000/height.raw', 'x');
write('mapa/000000/tile.raw', 'x');
write('mapa/000000/attr.atr', 'x');
const objects = [object(0, 11), object(1, 22), object(2, 33)];
if (broken) objects.push(object(3, 44), object(4, 55));
write('mapa/000000/areadata.txt', `AreaDataFile\n\nObjectCount ${objects.length}\n${objects.join('')}`);
write('textureset/textureset/mapa.txt', 'TextureSet\n\nTextureCount 1\n\nStart Texture001\n "d:\\ymir work\\terrainmaps\\a\\field.dds"\n 5.0\nEnd Texture001\n');
write('Terrain/ymir work/terrainmaps/a/field.dds', 'x');
write('ETC/ymir work/environment/a.msenv', 'x');
write('Property/property/t/tree.prt', prop(11, 'Tree', 'treefile', 'd:/ymir work/tree/a.spt'));
write('Tree/ymir work/tree/a.spt', 'spt');
write('TreeGeometry/manifest.json', JSON.stringify({ trees: { h: { source: 'Tree/ymir work/tree/a.spt', glb: 'h.glb', source_sha256: 'h' } } }));
write('TreeGeometry/h.glb', 'glb');
write('Property/property/b/house.prb', prop(22, 'Building', 'buildingfile', 'd:/ymir work/zone/house.gr2'));
if (!broken) write('Zone/ymir work/zone/house.gr2', 'gr2');
write('Property/property/e/fx.pre', prop(33, 'Effect', 'effectfile', 'd:/ymir work/effect/fx.mse'));
write('Effect/ymir work/effect/fx.mse', 'mse');
// 44 only exists outside Property/ (the runtime registry never sees it); 55 exists nowhere.
write('Zone/property/stray.prb', prop(44, 'Building', 'buildingfile', 'd:/ymir work/zone/house.gr2'));
write('root/npclist.txt', '2301\tent_a\n2302\tent_b\n');
const mob = (code, names) => {
const dir = `monster2/ymir work/monster2/${code}`;
write(`${dir}/${code}.gr2`, 'gr2');
write(`${dir}/${code}.dds`, 'dds');
write(`${dir}/${code}.msm`, `BaseModelFileName "d:\\ymir work\\monster2\\${code}\\${code}.gr2"\n`);
write(`${dir}/motlist.txt`, motlist(names));
for (const [, file] of names) write(`${dir}/${file}`, 'msa');
};
mob('ent_a', [['WAIT', '00.msa'], ['NORMAL_ATTACK', '20.msa'], ['FRONT_DAMAGE', '30.msa'], ['FRONT_DEAD', '31.msa']]);
const b = [['WAIT', '00.msa'], ['RUN', '10.msa'], ['NORMAL_ATTACK', '20.msa'], ['FRONT_DAMAGE', '30.msa']];
if (!broken) b.push(['FRONT_DEAD', '31.msa']);
mob('ent_b', b);
}
function run(args) {
const output = path.join(root, 'out', 'map-assets.json');
const result = spawnSync(process.execPath, [script, '--assets', root, '--output', output, ...args], { encoding: 'utf8' });
const report = fs.existsSync(output) ? JSON.parse(fs.readFileSync(output, 'utf8')) : null;
return { code: result.status, report, stderr: result.stderr };
}
try {
buildFixture({ broken: false });
let r = run(['--maps', 'mapa']);
check(r.code === 0 && r.report?.status === 'PASS', `complete fixture passes (exit ${r.code}) ${r.stderr}`);
check(r.report?.explained.some(e => e.scope === 'race:2301' && e.ref === 'run'), 'missing RUN/WALK is explained by the reference rule');
const map = r.report?.maps[0];
check(map?.static_spt[0]?.native_glb === 'TreeGeometry/h.glb' && map?.static_gr2[0]?.gr2 === 'Zone/ymir work/zone/house.gr2', 'tree SPT and building GR2 resolve');
check(map?.texture_set?.textures[0]?.resolved === 'Terrain/ymir work/terrainmaps/a/field.dds', 'TextureSet entries resolve through ymir work');
check(map?.effects[0]?.mse === 'Effect/ymir work/effect/fx.mse', 'effect property resolves');
check(r.report?.tree_monster_candidates.every(m => m.candidate_only && m.msm?.matches_runtime_gr2), 'MSM base model matches runtime GR2 and races stay candidates');
buildFixture({ broken: true });
r = run(['--maps', 'mapa']);
const kinds = new Set((r.report?.missing || []).map(m => `${m.kind}:${m.ref}`));
check(r.code === 1 && r.report?.status === 'FAIL', `broken fixture fails (exit ${r.code})`);
check(kinds.has('building_gr2:d:/ymir work/zone/house.gr2'), 'missing building GR2 is listed');
check(kinds.has('property:44') && r.report?.maps[0].unresolved_properties.some(p => p.property_id === 44 && p.found_outside_runtime_scope === 'Zone/property/stray.prb'), 'CRC outside Property/ is listed with its location');
check(kinds.has('property:55'), 'unknown CRC is listed');
check(kinds.has('motion:ent_b:dead'), 'missing death motion is not explained away');
check(!kinds.has('motion:ent_a:run'), 'RUN/WALK gap is explained, not missing');
fs.rmSync(path.join(root, 'mapa', '000000', 'attr.atr'));
r = run(['--maps', 'mapa']);
check((r.report?.missing || []).some(m => m.kind === 'tile_file'), 'missing tile file is listed');
write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: '' }));
r = run(['--config', path.join(root, 'scenario.local.json')]);
check(r.code === 2 && /map_key is empty/.test(r.stderr), `empty config map_key is BLOCKED (exit ${r.code})`);
write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: '../mapa' }));
r = run(['--config', path.join(root, 'scenario.local.json')]);
check(r.code === 2, 'map_key escaping the asset root is BLOCKED');
write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: 'mapa' }));
r = run(['--config', path.join(root, 'scenario.local.json')]);
check(r.report?.map_source === 'config' && r.report?.maps[0].key === 'mapa', 'config map_key is the audited map');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
console.log(`audit_playable_maps_test: failures=${failures}`);
process.exitCode = failures ? 1 : 0;
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
# MAP-02: run the packaged client in MT_TEST_MODE=forest_render and seal its report.
#
# - Offline: no server, no credentials. MT_ACCOUNT/MT_PASSWORD are removed from the
# child environment; nothing here reads or writes them. Do not add `set -x`.
# - Owns exactly one child PID; TERM, 10 s grace, KILL, then wait. Never pkills.
# - Maps come only from --maps or the config map_key (filled from the
# audit_playable_maps result); no map path is guessed here.
# - The client writes client-report.json/events/screenshots; this script writes
# report.json only after the real process exit and the full-log gate.
# - Metal screenshot sign-off stays a separate manual release item.
#
# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
repo="$PWD"
app_path=""
maps=""
config_path=""
viewpoints=""
races=""
output_dir=""
timeout_seconds=900
required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}"
kill_grace_seconds=10
suite="forest_render"
source script/playable_process_cleanup.sh
usage() {
cat <<'EOF'
用法: script/forest_map_render_test.sh --app APP (--maps KEYS | --config CONFIG) [选项]
选项:
--app APP 已签名的 arm64 包(build/export/mtgodot-poc.app);新测试模式必须重新构建后运行
--maps K1[,K2] map-assets.json 里的 map_key,逗号分隔(优先于 --config 的 map_key
--config FILE playable 配置;只读取 map_key/scenario_id
--viewpoints FILE 机位夹具(test/playable/forest-viewpoints.<name>.local.json);不给则只有自动候选
--races R1[,R2] 覆盖夹具里的怪物 vnum;不给且夹具为空时放 12 个候选树怪
--output DIR 本次运行目录,必须不存在或为空(默认 build/forest/run-<时间>-<pid>
--timeout-seconds N 墙钟超时,默认 900;超时 TERM,10 秒后 KILL
--help 显示本帮助
说明:
自动候选机位、未确认机位、缺失传送点都会使对应用例 BLOCKED;
即使自动用例 PASS,Metal 截图仍需人工签核(发布清单单独的 manual 项)。
环境变量:
MT_PLAYABLE_REQUIRED_ARCH 包必须包含的架构,默认 arm64
退出码: 0 PASS / 1 FAIL / 2 BLOCKED(配置或前置条件)/ 124 墙钟超时
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app_path="${2:-}"; shift 2 ;;
--maps) maps="${2:-}"; shift 2 ;;
--config) config_path="${2:-}"; shift 2 ;;
--viewpoints) viewpoints="${2:-}"; shift 2 ;;
--races) races="${2:-}"; shift 2 ;;
--output) output_dir="${2:-}"; shift 2 ;;
--timeout-seconds) timeout_seconds="${2:-}"; shift 2 ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
output_ready=0
blocked() {
echo "FOREST RENDER GATE: BLOCKED $*" >&2
if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi
exit 2
}
abs_file() { echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"; }
[ -n "$app_path" ] || blocked "需要 --app"
[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数"
engine="$app_path/Contents/MacOS/mtgodot-poc"
[ -x "$engine" ] || blocked "找不到可执行包:$app_path"
if [ -n "$config_path" ]; then
[ -f "$config_path" ] || blocked "配置不存在:$config_path"
config_path="$(abs_file "$config_path")"
fi
if [ -n "$viewpoints" ]; then
[ -f "$viewpoints" ] || blocked "机位夹具不存在:$viewpoints"
viewpoints="$(abs_file "$viewpoints")"
fi
if [ -n "$races" ] && ! [[ "$races" =~ ^[1-9][0-9]*(,[1-9][0-9]*)*$ ]]; then
blocked "--races 必须是逗号分隔的正整数 vnum"
fi
# Resolve the map list exactly as the client does (MT_FOREST_MAPS, else config map_key).
map_list="$(node -e '
const fs = require("node:fs");
const [maps, config] = process.argv.slice(1);
let keys = maps.split(",").map((s) => s.trim()).filter(Boolean);
if (keys.length === 0 && config) {
try { const key = String(JSON.parse(fs.readFileSync(config, "utf8")).map_key ?? "").trim(); if (key) keys = [key]; } catch {}
}
process.stdout.write(keys.join(","));
' "$maps" "$config_path")"
[ -n "$map_list" ] || blocked "没有地图:给 --maps,或在配置里填 map-assets.json 审计出的 map_key"
if [ -z "$output_dir" ]; then
output_dir="$repo/build/forest/run-$(date +%Y%m%d-%H%M%S)-$$"
fi
if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
blocked "输出目录已有内容,拒绝复用历史报告:$output_dir"
fi
mkdir -p "$output_dir"
output_dir="$(cd "$output_dir" && pwd)"
output_ready=1
if ! codesign --verify --strict "$app_path" >/dev/null 2>&1; then
blocked "签名校验失败:codesign --verify --strict $app_path"
fi
archs="$(lipo -archs "$engine" 2>/dev/null || true)"
case " $archs " in
*" $required_arch "*) ;;
*) blocked "包架构 [$archs] 不含 $required_arch" ;;
esac
run_id="$(basename "$output_dir")-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')"
log="$output_dir/client.log"
client_report="$output_dir/client-report.json"
final_report="$output_dir/report.json"
events="$output_dir/events.jsonl"
# Same formula as forest_map_render_test.gd required_case_ids(); the validator
# fails the run if the client declares a different list.
node -e '
const [file, suite, maps] = process.argv.slice(1);
const cases = ["MAP-FOREST-CONFIG"];
for (const key of maps.split(",")) {
const id = key.split("/").pop();
cases.push(`MAP-FOREST-LOAD-${id}`);
for (const kind of ["flat", "slope", "dense", "warp"]) cases.push(`MAP-FOREST-VP-${id}-${kind}`);
cases.push(`MAP-FOREST-MOTION-${id}`);
}
require("node:fs").writeFileSync(file, JSON.stringify({ schema_version: 1, suite, cases }, null, 2) + "\n");
' "$output_dir/required-cases.json" "$suite" "$map_list"
bash script/playable_build_info.sh "$app_path" >"$output_dir/build.json"
build_field() { node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"))[process.argv[2]]))' "$output_dir/build.json" "$1"; }
started_ms="$(($(date +%s) * 1000))"
set +e
# No credentials reach this child, so its log needs no redactor.
env -u MT_ASSETS -u MT_ACCOUNT -u MT_PASSWORD \
MT_TEST_MODE=forest_render \
MT_FOREST_MAPS="$map_list" \
MT_FOREST_VIEWPOINTS="$viewpoints" \
MT_FOREST_RACES="$races" \
MT_FOREST_TIMEOUT_SECONDS="$timeout_seconds" \
MT_PLAYABLE_CONFIG="$config_path" \
MT_PLAYABLE_RUN_ID="$run_id" \
MT_TEST_REPORT="$client_report" \
MT_TEST_EVENTS="$events" \
MT_TEST_OUTPUT="$output_dir" \
MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)" \
MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)" \
MT_BUILD_PCK_SHA256="$(build_field pck_sha256)" \
MT_BUILD_ARCH="$(build_field arch)" \
"$engine" >"$log" 2>&1 </dev/null &
child_pid=$!
SECONDS=0
timed_out=0
while kill -0 "$child_pid" 2>/dev/null; do
if [ "$SECONDS" -ge "$timeout_seconds" ]; then
timed_out=1
kill -TERM "$child_pid" 2>/dev/null
grace=0
while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do
sleep 1
grace=$((grace + 1))
done
killed=0
if kill -0 "$child_pid" 2>/dev/null; then kill -KILL "$child_pid" 2>/dev/null; killed=1; fi
echo "TIMEOUT term_grace_s=$grace killed=$killed" >>"$output_dir/gate.log"
break
fi
sleep 1
done
wait "$child_pid"
process_code=$?
child_pid=""
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
--log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \
--suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \
--events "$events" --started-ms "$started_ms" --require-pass
gate_status=$?
set -e
echo "FOREST RENDER GATE: Metal 截图人工签核仍是单独的 manual 项($output_dir/forest-*.png + forest-map-evidence.json"
if [ "$timed_out" -eq 1 ]; then
echo "FOREST RENDER GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2
exit 124
fi
case "$gate_status" in
0) echo "FOREST RENDER GATE: PASS run_id=$run_id report=$final_report" ;;
2) echo "FOREST RENDER GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;;
*) echo "FOREST RENDER GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;;
esac
exit "$gate_status"
+27 -5
View File
@@ -8,7 +8,10 @@
# ./script/live_smoke_test.sh --no-reconnect
#
# 账号和密码优先从环境变量读取;未提供时安全地交互输入,不写入脚本。
# 服务器地址与客户端一样经 project/net/serverinfo.gd 解析,不在脚本里写死。
# 不要在本脚本中启用 set -x。
set -euo pipefail
set +x
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -37,6 +40,7 @@ usage() {
环境变量:
MT_ACCOUNT / MT_PASSWORD 可预先提供账号密码,未提供时交互输入
MT_CHAR_SLOT=N 指定角色槽位;不设置时自动选择第一个角色
MT_GODOT 解析 serverinfo 用的 godot,默认 godot
EOF
}
@@ -61,7 +65,8 @@ if [ -f "$CONFIG_FILE" ]; then
fi
if [ -z "${MT_ACCOUNT:-}" ]; then
read -r -p "账号: " MT_ACCOUNT
read -r -s -p "账号: " MT_ACCOUNT
echo
fi
if [ -z "${MT_PASSWORD:-}" ]; then
read -r -s -p "密码: " MT_PASSWORD
@@ -85,9 +90,19 @@ if [ ! -x "$EXECUTABLE" ]; then
fi
echo "== check server ports =="
for port in 11000 13002; do
if ! nc -G 3 -z 192.168.21.203 "$port" >/dev/null 2>&1; then
echo "服务器端口不可达:192.168.21.203:$port" >&2
# 登录页默认 server 0 / 第一个频道;与 AppFlow 同一 ServerInfo 解析。
ADDRESS_LINE="$("${MT_GODOT:-godot}" --headless --path project --script print_server_address.gd 2>/dev/null | grep '^SERVER_ADDRESS ' || true)"
if [ -z "$ADDRESS_LINE" ]; then
echo "无法从 serverinfo 解析服务器地址" >&2
exit 2
fi
read -r AUTH_HOST AUTH_PORT GAME_HOST GAME_PORT <<<"$(node -e '
const a = JSON.parse(process.argv[1].slice("SERVER_ADDRESS ".length));
process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" "));
' "$ADDRESS_LINE")"
for endpoint in "$AUTH_HOST:$AUTH_PORT" "$GAME_HOST:$GAME_PORT"; do
if ! nc -G 3 -z "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then
echo "服务器端口不可达:$endpoint" >&2
exit 3
fi
done
@@ -111,7 +126,8 @@ echo "== run live smoke =="
echo "log: $LOG"
echo "report: $REPORT"
set +e
"$EXECUTABLE" 2>&1 | tee "$LOG"
# 日志落盘前脱敏;PIPESTATUS[0] 仍是 APP 的真实退出码。
env -u MT_ASSETS "$EXECUTABLE" 2>&1 | node script/redact_stream.mjs | tee "$LOG"
APP_STATUS=${PIPESTATUS[0]}
set -e
@@ -140,6 +156,12 @@ if [ "$APP_STATUS" -ne 0 ]; then
exit "$APP_STATUS"
fi
# 与 package_render_test.sh / validate_playable_report.mjs 相同的完整日志门禁。
if grep -nE 'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed' "$LOG"; then
echo "LIVE_SMOKE RESULT: FAIL (log exit gate)" >&2
exit 1
fi
if [ "$REPORT_STATUS" != "PASS" ]; then
echo "LIVE_SMOKE RESULT: FAIL (report status=$REPORT_STATUS)" >&2
exit 1
+2 -2
View File
@@ -10,8 +10,8 @@ mkdir -p "$output_dir"
status=0
env -u MT_ASSETS MT_TEST_MODE=render MT_RENDER_OUTPUT="$output_dir" \
"$app_path/Contents/MacOS/mtgodot-poc" --quit-after 1800 >"$output_dir/client.log" 2>&1 || status=$?
if [ "$status" -ne 0 ] || ! rg -q '^PKGRENDER: PASS$' "$output_dir/client.log" || \
rg -q 'SCRIPT ERROR|^ERROR:|leaked at exit|shaders of type .* were never freed' "$output_dir/client.log"; then
if [ "$status" -ne 0 ] || ! grep -qE '^PKGRENDER: PASS$' "$output_dir/client.log" || \
grep -qE 'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed' "$output_dir/client.log"; then
echo "FAIL: package load/exit gate (exit=$status): $output_dir/client.log"
exit 1
fi
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Print the candidate package identity as JSON (engine/extension/pck sha256 +
# architectures). Shared by run_client_gate.sh and playable_test.sh so the
# per-run build.json and the release manifest are computed the same way.
set -euo pipefail
if [ "${1:-}" = "--help" ] || [ "$#" -ne 1 ]; then
echo "用法: script/playable_build_info.sh APP" >&2
[ "${1:-}" = "--help" ] && exit 0
exit 2
fi
app="$1"
engine="$app/Contents/MacOS/mtgodot-poc"
pck="$app/Contents/Resources/mtgodot-poc.pck"
dylib="$(find "$app/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' 2>/dev/null | LC_ALL=C sort | head -n 1)"
sha256_of() {
if [ -n "$1" ] && [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else printf ''; fi
}
archs=""
if [ -f "$engine" ]; then archs="$(lipo -archs "$engine" 2>/dev/null | tr ' ' '+' || true)"; fi
node -e '
const [engine, extension, pck, arch] = process.argv.slice(1);
process.stdout.write(JSON.stringify({ engine_sha256: engine, extension_sha256: extension, pck_sha256: pck, arch }) + "\n");
' "$(sha256_of "$engine")" "$(sha256_of "$dylib")" "$(sha256_of "$pck")" "$archs"
-14
View File
@@ -1,14 +0,0 @@
extends SceneTree
const Config = preload("res://testing/playable_config.gd")
func _init() -> void:
var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG")
var result := Config.load_file(path)
if not result.ok:
for error in result.errors:
printerr("CONFIG: " + String(error))
quit(2)
return
print("PLAYABLE CONFIG: PASS " + path)
quit(0)
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env node
/* STB-DISCONNECT-01 local transport-fault proxy for ONE test client connection.
*
* playable_fault_proxy.mjs --route LISTEN_PORT=UPSTREAM_HOST:UPSTREAM_PORT [--route ...]
* --events events.jsonl --log fault-proxy.jsonl [--run-id RUN_ID] [--poll-ms 200]
*
* - Listens on 127.0.0.1 only and forwards bytes to the upstream test server. The
* scenario's server.* points the client at these loopback ports; nothing else on
* the machine is touched: no routes, firewall rules, other clients or production.
* - Tails the client's events.jsonl and acts on each `fault_request` exactly once:
* close destroy the proxied connections ("connection closed")
* unreachable destroy them AND stop listening for payload.unreachable_seconds
* (capped at 20 s) so reconnect attempts are refused, then listen again
* - Every action is appended to --log as JSON (wall_ms, action, type, attempt, ...).
* The client, not this proxy, decides whether recovery happened.
* - Exits 0 on SIGTERM/SIGINT after closing its sockets. It never signals any process.
*
* Known limit: a server-side warp that hands out a direct game address bypasses the proxy.
* exit: 0 stopped, 2 usage error or a listen port could not be opened. */
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const LISTEN_HOST = '127.0.0.1';
const MAX_UNREACHABLE_S = 20;
function usage(stream = process.stderr) {
stream.write('usage: playable_fault_proxy.mjs --route LISTEN_PORT=HOST:PORT [--route ...] --events events.jsonl --log fault-proxy.jsonl [--run-id ID] [--poll-ms 200]\n'
+ ' listens on 127.0.0.1 only; acts on fault_request events (close | unreachable)\n');
}
const isPort = (value) => Number.isInteger(value) && value >= 1 && value <= 65535;
export function parseArgs(argv) {
const options = { routes: [], poll_ms: 200 };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--help') { options.help = true; continue; }
const value = argv[i + 1];
if (value === undefined) throw new Error(`missing value for ${arg}`);
i += 1;
if (arg === '--route') {
const match = /^(\d+)=([A-Za-z0-9.:-]+):(\d+)$/.exec(value);
if (!match) throw new Error(`bad --route ${value}`);
const route = { listen: Number(match[1]), host: match[2], port: Number(match[3]) };
if (!isPort(route.listen) || !isPort(route.port)) throw new Error(`bad port in --route ${value}`);
if (['127.0.0.1', 'localhost', '::1'].includes(route.host) && route.port === route.listen) throw new Error(`route ${value} loops back to itself`);
if (options.routes.some((r) => r.listen === route.listen)) throw new Error(`duplicate listen port ${route.listen}`);
options.routes.push(route);
} else if (arg === '--events') options.events = value;
else if (arg === '--log') options.log = value;
else if (arg === '--run-id') options.run_id = value;
else if (arg === '--poll-ms') options.poll_ms = Number(value);
else throw new Error(`unknown option ${arg}`);
}
if (!options.help && (options.routes.length === 0 || !options.events || !options.log
|| !Number.isInteger(options.poll_ms) || options.poll_ms < 20)) throw new Error('--route, --events and --log are required');
return options;
}
/** Incremental JSONL reader: returns complete new lines since the last call. */
export function tailer(file) {
let offset = 0;
let partial = '';
return () => {
let size;
try { size = fs.statSync(file).size; } catch { return []; }
if (size < offset) { offset = 0; partial = ''; }
if (size === offset) return [];
const fd = fs.openSync(file, 'r');
const buffer = Buffer.alloc(size - offset);
fs.readSync(fd, buffer, 0, buffer.length, offset);
fs.closeSync(fd);
offset = size;
const text = partial + buffer.toString('utf8');
const lines = text.split('\n');
partial = lines.pop();
return lines.filter((line) => line.trim() !== '');
};
}
export class FaultProxy {
constructor(options) {
this.options = options;
this.servers = new Map();
this.pairs = new Set();
this.seen = new Set();
this.unreachableTimer = null;
this.stopped = false;
this.read = tailer(options.events);
}
log(action, detail = {}) {
fs.appendFileSync(this.options.log, `${JSON.stringify({ wall_ms: Date.now(), action, ...detail })}\n`);
}
listen(route) {
return new Promise((resolve, reject) => {
const server = net.createServer((client) => this.accept(route, client));
server.once('error', reject);
server.listen(route.listen, LISTEN_HOST, () => {
server.off('error', reject);
this.servers.set(route.listen, server);
resolve();
});
});
}
accept(route, client) {
const upstream = net.connect(route.port, route.host);
const pair = { client, upstream };
this.pairs.add(pair);
const drop = () => {
if (!this.pairs.delete(pair)) return;
client.destroy();
upstream.destroy();
};
client.on('error', drop).on('close', drop);
upstream.on('error', drop).on('close', drop);
client.pipe(upstream);
upstream.pipe(client);
this.log('accepted', { listen_port: route.listen, open: this.pairs.size });
}
dropAll() {
const count = this.pairs.size;
for (const pair of [...this.pairs]) {
this.pairs.delete(pair);
pair.client.destroy();
pair.upstream.destroy();
}
return count;
}
/** Stops accepting, drops open connections (server.close waits for them), returns the drop count. */
async closeListeners() {
const closing = [...this.servers.values()].map((server) => new Promise((resolve) => server.close(resolve)));
this.servers.clear();
const dropped = this.dropAll();
await Promise.all(closing);
return dropped;
}
async start() {
for (const route of this.options.routes) await this.listen(route);
this.log('listening', { listen_ports: this.options.routes.map((r) => r.listen) });
this.poller = setInterval(() => { this.poll().catch((error) => this.log('error', { reason: String(error.message).slice(0, 120) })); }, this.options.poll_ms);
}
async poll() {
// setInterval does not wait for an async fault; never run two polls at once.
if (this.polling) return;
this.polling = true;
try { await this.drain(); } finally { this.polling = false; }
}
async drain() {
for (const line of this.read()) {
let event;
try { event = JSON.parse(line); } catch { continue; }
if (event?.kind !== 'fault_request') continue;
if (this.options.run_id && event.run_id !== this.options.run_id) { this.log('ignored_foreign_run', {}); continue; }
const payload = event.payload ?? {};
const key = `${event.monotonic_us}:${payload.type}:${payload.attempt}`;
if (this.seen.has(key)) continue;
this.seen.add(key);
await this.fault(payload);
}
}
async fault(payload) {
const detail = { type: payload.type, attempt: payload.attempt, round: payload.round };
if (payload.type === 'close') {
this.log('close', { ...detail, closed: this.dropAll() });
} else if (payload.type === 'unreachable') {
if (this.unreachableTimer) { this.log('rejected', { ...detail, reason: 'unreachable window already active' }); return; }
const seconds = Math.min(MAX_UNREACHABLE_S, Math.max(1, Number.parseInt(payload.unreachable_seconds, 10) || 1));
const closed = await this.closeListeners();
this.log('unreachable_start', { ...detail, closed, seconds });
this.unreachableTimer = setTimeout(async () => {
this.unreachableTimer = null;
if (this.stopped) return;
try {
for (const route of this.options.routes) await this.listen(route);
this.log('unreachable_end', detail);
} catch (error) {
this.log('error', { ...detail, reason: `relisten failed: ${error.code || error.message}` });
}
}, seconds * 1000);
} else {
this.log('rejected', { ...detail, reason: 'unknown fault type' });
}
}
async stop() {
if (this.stopped) return;
this.stopped = true;
clearInterval(this.poller);
if (this.unreachableTimer) clearTimeout(this.unreachableTimer);
this.dropAll();
await this.closeListeners();
this.log('stopped', {});
}
}
async function main() {
let options;
try { options = parseArgs(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; }
if (options.help) { usage(process.stdout); return 0; }
const proxy = new FaultProxy(options);
try { await proxy.start(); } catch (error) {
console.error(`FAULT PROXY: cannot listen (${error.code || error.message})`);
await proxy.stop();
return 2;
}
await new Promise((resolve) => {
process.once('SIGTERM', resolve);
process.once('SIGINT', resolve);
});
await proxy.stop();
return 0;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main();
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
/* STB-DISCONNECT-01 fault proxy regression. Uses only 127.0.0.1 ephemeral ports and a
* local echo server as the "upstream"; never contacts the test server. Proves that the
* proxy forwards bytes, closes the connection on `close`, refuses connections for the
* `unreachable` window and listens again, acts on each request once, ignores foreign
* run_ids, and stops cleanly on SIGTERM. */
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { parseArgs } from './playable_fault_proxy.mjs';
const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_fault_proxy.mjs');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-fault-proxy.'));
let failures = 0;
const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } };
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function until(predicate, timeoutMs, stepMs = 50) {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
if (await predicate()) return true;
await delay(stepMs);
}
return false;
}
function freePort() {
return new Promise((resolve) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => resolve(port)); });
});
}
function connect(port) {
return new Promise((resolve) => {
const socket = net.connect(port, '127.0.0.1');
const state = { socket, closed: false, data: '', error: null };
socket.on('data', (chunk) => { state.data += chunk.toString(); });
socket.on('close', () => { state.closed = true; });
socket.once('connect', () => resolve(state));
socket.once('error', (error) => { state.error = error.code; resolve(state); });
});
}
const logRows = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []);
const actions = (file, action) => logRows(file).filter((row) => row.action === action);
let usClock = 1000;
const request = (file, type, attempt, extra = {}) => fs.appendFileSync(file, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a',
case_id: 'STB-DISCONNECT-01', connection_epoch: 2, stage: 'FAULT', kind: 'fault_request', actor_vid: 0, target_vid: 0,
payload: { type, attempt, round: 0, timeout_ms: 30000, unreachable_seconds: type === 'unreachable' ? 2 : 0, ...extra } })}\n`);
// Argument contract.
{
const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' });
check(help.status === 0 && /127\.0\.0\.1/.test(help.stdout), '--help documents loopback-only listening');
for (const [label, args] of [
['no routes', ['--events', 'e', '--log', 'l']],
['malformed route', ['--route', 'abc', '--events', 'e', '--log', 'l']],
['route to itself', ['--route', '4000=127.0.0.1:4000', '--events', 'e', '--log', 'l']],
['port out of range', ['--route', '70000=10.0.0.1:1', '--events', 'e', '--log', 'l']],
['unknown option', ['--route', '4000=10.0.0.1:1', '--events', 'e', '--log', 'l', '--listen-host', '0.0.0.0']],
]) {
const run = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' });
check(run.status === 2, `usage error exits 2: ${label}`);
}
check(parseArgs(['--route', '4000=192.0.2.1:11000', '--events', 'e', '--log', 'l']).routes[0].host === '192.0.2.1', 'route parses upstream host');
}
// Live behaviour against a local echo upstream.
const upstream = net.createServer((socket) => socket.pipe(socket));
await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve));
const upstreamPort = upstream.address().port;
const listenPort = await freePort();
const events = path.join(root, 'events.jsonl');
const log = path.join(root, 'fault-proxy.jsonl');
const proxy = spawn(process.execPath, [script, '--route', `${listenPort}=127.0.0.1:${upstreamPort}`, '--events', events,
'--log', log, '--run-id', 'run-a', '--poll-ms', '50'], { stdio: ['ignore', 'ignore', 'pipe'] });
let exited = null;
proxy.on('exit', (code) => { exited = code; });
check(await until(() => actions(log, 'listening').length === 1, 5000), 'proxy logs listening before any fault');
let a = await connect(listenPort);
a.socket.write('ping');
check(await until(() => a.data === 'ping', 2000), 'bytes are forwarded to the upstream and back');
request(events, 'close', 1);
check(await until(() => a.closed, 2000), 'close request drops the proxied connection');
check(actions(log, 'close')[0]?.closed === 1, 'close action records the dropped connection count');
let b = await connect(listenPort);
b.socket.write('again');
check(!b.error && await until(() => b.data === 'again', 2000), 'the listener stays open after close');
// The same line again (same monotonic_us/type/attempt) is not a second fault.
const lines = fs.readFileSync(events, 'utf8').trim().split('\n');
fs.appendFileSync(events, `${lines.at(-1)}\n`);
await delay(300);
check(!b.closed && actions(log, 'close').length === 1, 'a repeated request line is acted on once');
fs.appendFileSync(events, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'other-run', kind: 'fault_request', payload: { type: 'close', attempt: 9 } })}\n`);
await delay(300);
check(!b.closed && actions(log, 'ignored_foreign_run').length === 1, 'fault requests from another run are ignored');
// Partial line: only acted on once the newline arrives.
const partial = JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a', kind: 'fault_request',
payload: { type: 'unreachable', attempt: 1, unreachable_seconds: 2 } });
fs.appendFileSync(events, partial.slice(0, 20));
await delay(300);
check(!b.closed, 'a half-written event line is not parsed early');
const started = Date.now();
fs.appendFileSync(events, `${partial.slice(20)}\n`);
check(await until(() => b.closed, 2000), 'unreachable request drops the connection');
await until(() => actions(log, 'unreachable_start').length === 1, 1000);
const refused = await connect(listenPort);
check(refused.error === 'ECONNREFUSED', `connections are refused during the unreachable window (${refused.error})`);
check(await until(() => actions(log, 'unreachable_end').length === 1, 5000), 'listener comes back after unreachable_seconds');
const elapsed = Date.now() - started;
check(elapsed >= 1900 && elapsed < 4500, `unreachable window lasts about 2 s (${elapsed} ms)`);
const c = await connect(listenPort);
c.socket.write('back');
check(!c.error && await until(() => c.data === 'back', 2000), 'forwarding works again after the window');
c.socket.destroy();
proxy.kill('SIGTERM');
check(await until(() => exited !== null, 5000) && exited === 0, 'SIGTERM stops the proxy with exit 0');
check(actions(log, 'stopped').length === 1, 'stop is logged');
const after = await connect(listenPort);
check(after.error === 'ECONNREFUSED', 'no listener remains after stop');
check(!fs.readFileSync(log, 'utf8').includes('ping'), 'the action log never contains payload bytes');
// A port already in use is a startup error, not a silent run.
{
const busy = net.createServer();
await new Promise((resolve) => busy.listen(0, '127.0.0.1', resolve));
const run = spawnSync(process.execPath, [script, '--route', `${busy.address().port}=127.0.0.1:${upstreamPort}`, '--events', events,
'--log', path.join(root, 'busy.jsonl')], { encoding: 'utf8', timeout: 10000 });
check(run.status === 2 && /cannot listen/.test(run.stderr), 'busy listen port exits 2');
busy.close();
}
upstream.close();
fs.rmSync(root, { recursive: true, force: true });
console.log(failures === 0 ? 'PASS: playable_fault_proxy_test' : `FAIL: playable_fault_proxy_test (${failures})`);
process.exitCode = failures === 0 ? 0 : 1;
+438 -29
View File
@@ -1,44 +1,453 @@
#!/usr/bin/env bash
# INF-02 negative/positive checks. Uses only static fixtures and a temporary
# output directory; it never starts the game or contacts the server.
# INF-02 runner/exit-gate regression. Builds a throw-away ad-hoc signed arm64
# app whose executable execs test/playable/fake_client.sh, listens on a local
# 127.0.0.1 port, and drives script/run_client_gate.sh through PASS and every
# failure path. Never starts the real client and never contacts the test server.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
repo="$PWD"
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/mt-playable-gate.XXXXXX")"
trap 'rm -R "$tmp_dir"' EXIT
listener_pid=""
cleanup() {
if [ -n "$listener_pid" ]; then kill "$listener_pid" 2>/dev/null || true; fi
if [ -z "${MT_GATE_TEST_KEEP:-}" ]; then rm -R "$tmp_dir"; else echo "kept $tmp_dir"; fi
}
trap cleanup EXIT
node script/validate_playable_report.mjs \
--report test/playable/report.valid.json \
--output "$tmp_dir/valid.json" \
--log test/playable/clean.log --process-code 0 --require-pass
failures=0
pass() { echo "ok - $*"; }
fail() { echo "not ok - $*" >&2; failures=$((failures + 1)); }
expect_code() { # expected actual label
if [ "$1" -eq "$2" ]; then pass "$3 (exit $2)"; else fail "$3: expected exit $1, got $2"; fi
}
if node script/validate_playable_report.mjs \
--report test/playable/report.blocked.json \
--output "$tmp_dir/blocked.json" \
--log test/playable/clean.log --process-code 0 --require-pass; then
echo "FAIL: BLOCKED fixture was accepted" >&2
exit 1
# ---------- validator unit checks on static fixtures ----------
fixture="$repo/test/playable"
validate_fixture() { # report log process_code timed_out [extra...]
local report="$1" log="$2" code="$3" timed="$4"
shift 4
rm -f "$tmp_dir/sealed.json"
node script/validate_playable_report.mjs --report "$report" --output "$tmp_dir/sealed.json" --log "$log" \
--process-code "$code" --timed-out "$timed" --run-id fixture-run --suite playable \
--required-cases "$tmp_dir/v/required-cases.json" --build "$tmp_dir/v/build.json" \
--events "$tmp_dir/v/events.jsonl" --started-ms "$(($(date +%s) * 1000))" --require-pass "$@" >/dev/null 2>&1
}
reset_fixture() {
rm -rf "$tmp_dir/v"
mkdir -p "$tmp_dir/v"
cp "$fixture/report.valid.json" "$tmp_dir/v/client-report.json"
cp "$fixture/required-cases.fixture.json" "$tmp_dir/v/required-cases.json"
cp "$fixture/build.fixture.json" "$tmp_dir/v/build.json"
cp "$fixture/events.valid.jsonl" "$tmp_dir/v/events.jsonl"
}
mutate() { # js expression body operating on `r`
node -e 'const fs=require("fs");const f=process.argv[1];const r=JSON.parse(fs.readFileSync(f,"utf8"));'"$1"';fs.writeFileSync(f,JSON.stringify(r));' "$tmp_dir/v/client-report.json"
}
code_of() { set +e; "$@"; local c=$?; set -e; echo "$c"; }
quiet() { "$@" >/dev/null 2>&1; }
reset_fixture
expect_code 0 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: valid fixture"
sealed_checked="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.exit_gate.checked===true&&r.exit_gate.process_code===0))' "$tmp_dir/sealed.json")"
[ "$sealed_checked" = "true" ] && pass "validator: exit_gate sealed" || fail "validator: exit_gate not sealed"
cp "$fixture/report.blocked.json" "$tmp_dir/v/client-report.json"
expect_code 2 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: BLOCKED case is not releasable"
reset_fixture
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 1 0)" "validator: nonzero child exit"
for root_value in null '[]' '"invalid"' 42; do
reset_fixture
printf '%s\n' "$root_value" >"$tmp_dir/v/client-report.json"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: invalid root $root_value"
if [ -f "$tmp_dir/sealed.json" ]; then pass "validator: invalid root sealed as FAIL"; else fail "validator: invalid root crashed before sealing"; fi
done
reset_fixture
printf 'null\n' >"$tmp_dir/v/events.jsonl"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: null event"
[ -f "$tmp_dir/sealed.json" ] && pass "validator: null event sealed as FAIL" || fail "validator: null event crashed before sealing"
reset_fixture
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 1)" "validator: timed out"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/rid-warning.log" 0 0)" "validator: RID warning in log"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$tmp_dir/missing.log" 0 0)" "validator: missing log"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/missing.json" "$fixture/clean.log" 0 0)" "validator: missing client report"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0 --redactor-code 143)" "validator: redactor killed"
mutate 'r.schema_version=2'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: unknown schema"
reset_fixture; mutate 'r.suite="full"'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: suite mismatch"
reset_fixture; mutate 'r.cases=[];r.required_cases=[];r.coverage={required:0,passed:0}'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: empty cases"
reset_fixture; mutate 'r.cases.push(r.cases[0]);r.coverage.required=2;r.coverage.passed=2'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: duplicate case"
reset_fixture; mutate 'r.build.pck_sha256=""'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing hash"
reset_fixture; mutate 'r.build.engine_sha256="d".repeat(64)'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: build differs from runner build.json"
reset_fixture; mutate 'r.cases[0].evidence=["shot.png"]'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing evidence file"
reset_fixture; mutate 'r.cases[0].evidence=["../../etc/hosts"]'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: evidence path escape"
reset_fixture; mutate 'r.event_count=5'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: event_count mismatch"
reset_fixture; sed 's/fixture-run/other-run/' "$fixture/events.valid.jsonl" >"$tmp_dir/v/events.jsonl"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: foreign run_id in events"
reset_fixture; node -e 'require("fs").writeFileSync(process.argv[1], JSON.stringify({schema_version:1,suite:"playable",cases:["FIXTURE-01","FIXTURE-02"]}))' "$tmp_dir/v/required-cases.json"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: required count differs from case list"
reset_fixture; touch -t 200001010000 "$tmp_dir/v/client-report.json"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: stale report mtime"
reset_fixture; mutate 'r.cases[0].reason="fixture-secret-value"'
code="$(MT_PASSWORD=fixture-secret-value code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)"
expect_code 1 "$code" "validator: credential literal in client report"
if grep -q "fixture-secret-value" "$tmp_dir/sealed.json"; then fail "validator: secret copied into sealed report"; else pass "validator: secret not persisted"; fi
# ---------- log redactor ----------
redacted="$(printf 'user fixture-acct pass fixture-acct-pw\n' | MT_ACCOUNT=fixture-acct MT_PASSWORD=fixture-acct-pw node script/redact_stream.mjs)"
[ "$redacted" = "user [redacted] pass [redacted]" ] && pass "redactor: longest literal first" || fail "redactor output: $redacted"
# ---------- scripts never trace ----------
if grep -nE '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*x' script/run_client_gate.sh script/playable_test.sh script/playable_soak.sh script/live_smoke_test.sh script/forest_map_render_test.sh 2>/dev/null; then
fail "a launch script enables set -x"
else
pass "launch scripts never enable set -x"
fi
if node script/validate_playable_report.mjs \
--report test/playable/report.valid.json \
--output "$tmp_dir/exit.json" \
--log test/playable/clean.log --process-code 1 --require-pass; then
echo "FAIL: non-zero child exit was accepted" >&2
exit 1
# ---------- example config is rejected ----------
if MT_PLAYABLE_VALIDATE_CONFIG="$repo/test/playable/scenario.example.json" MT_PLAYABLE_ASSETS="$tmp_dir" \
"${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd >/dev/null 2>&1; then
fail "empty example scenario accepted"
else
pass "empty example scenario rejected"
fi
if node script/validate_playable_report.mjs \
--report test/playable/report.valid.json \
--output "$tmp_dir/rid.json" \
--log test/playable/rid-warning.log --process-code 0 --require-pass; then
echo "FAIL: renderer warning was accepted" >&2
exit 1
# ---------- fake signed arm64 app ----------
app="$tmp_dir/Fake.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Frameworks" "$app/Contents/Resources"
cat >"$tmp_dir/fake.c" <<'EOF'
#include <stdlib.h>
#include <unistd.h>
int main(void) {
const char *script = getenv("MT_FAKE_CHILD_SCRIPT");
if (script == NULL) return 64;
execl("/bin/sh", "sh", script, (char *)0);
return 127;
}
EOF
printf 'int fake_extension(void) { return 1; }\n' >"$tmp_dir/ext.c"
cc -arch arm64 -o "$app/Contents/MacOS/mtgodot-poc" "$tmp_dir/fake.c"
cc -arch arm64 -dynamiclib -o "$app/Contents/Frameworks/libmtgodot.macos.template_release.dylib" "$tmp_dir/ext.c"
printf 'fake pck\n' >"$app/Contents/Resources/mtgodot-poc.pck"
cat >"$app/Contents/Info.plist" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleExecutable</key><string>mtgodot-poc</string>
<key>CFBundleIdentifier</key><string>test.mt.playable-gate</string>
<key>CFBundlePackageType</key><string>APPL</string>
</dict></plist>
EOF
codesign --force -s - "$app/Contents/Frameworks/libmtgodot.macos.template_release.dylib" >/dev/null 2>&1
codesign --force -s - "$app" >/dev/null 2>&1
# Local listener so the TCP precheck has a real endpoint; nothing reads from it.
node -e '
const server = require("node:net").createServer((socket) => socket.destroy());
server.listen(0, "127.0.0.1", () => console.log(server.address().port));
' >"$tmp_dir/port" &
listener_pid=$!
for _ in 1 2 3 4 5 6 7 8 9 10; do [ -s "$tmp_dir/port" ] && break; sleep 0.2; done
port="$(tr -d '\n' <"$tmp_dir/port")"
printf 'fixture\t127.0.0.1\t%s\t127.0.0.1\t%s\t1\t10\t0\n' "$port" "$port" >"$tmp_dir/serverlist.txt"
map_key="outdoortrent/metin2_map_trent"
mkdir -p "$tmp_dir/assets/$map_key"
printf 'ScriptType\tMapSetting\nBasePosition\t0\t0\nMapSize\t2\t2\n' >"$tmp_dir/assets/$map_key/Setting.txt"
node -e '
const [file, port, key] = process.argv.slice(1);
require("node:fs").writeFileSync(file, JSON.stringify({
schema_version: 1, scenario_id: "gate-fixture", protocol: "classic",
server: { server_index: 0, channel: 1, auth_host: "127.0.0.1", auth_port: Number(port), game_host: "127.0.0.1", game_port: Number(port) },
character_slot: 0, map_key: key, waypoints_cm: [[1000, 2000], [1600, 2000]],
allowed_mob_vnums: [101], allowed_drop_vnums: [19],
skill_cases: [{ case_id: "strike", skill_id: 5, target: "enemy", repeats: 2, required_evidence: ["cast_started", "damage"] }],
resolution: [1280, 720], loops: 1, timeout_seconds: 900,
}, null, 2));
' "$tmp_dir/scenario.local.json" "$port" "$map_key"
export MT_ACCOUNT="gate-fixture-account"
export MT_PASSWORD="gate-fixture-password"
export MT_FAKE_CHILD_SCRIPT="$repo/test/playable/fake_client.sh"
run_gate() { # scenario output [extra args...]
local scenario="$1" out="$2"
shift 2
MT_FAKE_SCENARIO="$scenario" bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$out" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" "$@" >"$out.stdout" 2>&1
}
report_status() { node -e 'try{process.stdout.write(require(process.argv[1]).status)}catch{process.stdout.write("MISSING")}' "$1/report.json"; }
cancel_gate() { # script output [runner args...]
local runner="$1" out="$2" owner child="" code pid_file
shift 2
MT_FAKE_SCENARIO=cancel bash "$runner" --app "$app" --output "$out" "$@" >"$out.stdout" 2>&1 &
owner=$!
for ((attempt=0; attempt<100; attempt++)); do
pid_file="$(find "$out" -name fake-child.pid -print -quit 2>/dev/null || true)"
if [ -n "$pid_file" ] && [ -s "$pid_file" ]; then child="$(<"$pid_file")"; break; fi
kill -0 "$owner" 2>/dev/null || break
sleep 0.1
done
kill -TERM "$owner" 2>/dev/null || true
set +e
wait "$owner"
code=$?
set -e
expect_code 143 "$code" "cancel: $runner preserves signal exit"
if [ -n "$child" ] && ! kill -0 "$child" 2>/dev/null; then
pass "cancel: $runner reaps its client"
else
fail "cancel: $runner left a client alive or never started one"
if [ -n "$child" ]; then kill -KILL "$child" 2>/dev/null || true; fi
fi
}
cancel_gate script/run_client_gate.sh "$tmp_dir/run-cancel" --config "$tmp_dir/scenario.local.json" \
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt"
cancel_gate script/forest_map_render_test.sh "$tmp_dir/forest-cancel" --maps "$map_key"
cancel_gate script/playable_test.sh "$tmp_dir/batch-cancel" --config "$tmp_dir/scenario.local.json" \
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt"
expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-pass")" "runner: PASS"
[ "$(report_status "$tmp_dir/run-pass")" = "PASS" ] && pass "runner: sealed report PASS" || fail "runner: sealed report not PASS"
expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-pass-2")" "runner: second independent PASS"
expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-full" --suite full)" "runner: full suite PASS"
expect_code 1 "$(code_of run_gate exit3 "$tmp_dir/run-exit3")" "runner: exit 3 after client PASS"
[ "$(report_status "$tmp_dir/run-exit3")" = "FAIL" ] && pass "runner: exit 3 sealed FAIL" || fail "runner: exit 3 not sealed FAIL"
expect_code 1 "$(code_of run_gate signal "$tmp_dir/run-signal")" "runner: child killed by signal"
expect_code 1 "$(code_of run_gate stale "$tmp_dir/run-stale")" "runner: stale run_id"
expect_code 1 "$(code_of run_gate no-report "$tmp_dir/run-noreport")" "runner: missing client report"
expect_code 1 "$(code_of run_gate rid "$tmp_dir/run-rid")" "runner: RID warning after PASS"
expect_code 1 "$(code_of run_gate leak "$tmp_dir/run-leak")" "runner: leak warning after PASS"
expect_code 1 "$(code_of run_gate missing-case "$tmp_dir/run-missing-case")" "runner: missing required case"
expect_code 1 "$(code_of run_gate missing-evidence "$tmp_dir/run-missing-evidence")" "runner: missing evidence file"
expect_code 2 "$(code_of run_gate blocked "$tmp_dir/run-blocked")" "runner: BLOCKED cases"
expect_code 0 "$(code_of run_gate print-secret "$tmp_dir/run-secret")" "runner: printed credentials are redacted before disk"
if grep -rqF -e "$MT_ACCOUNT" -e "$MT_PASSWORD" "$tmp_dir/run-secret" "$tmp_dir/run-secret.stdout"; then
fail "runner: credential literal reached the run directory"
else
pass "runner: no credential literal in run directory"
fi
if MT_PLAYABLE_VALIDATE_CONFIG="$PWD/test/playable/scenario.example.json" \
godot --headless --path project --script playable_config_gate.gd; then
echo "FAIL: empty scenario configuration was accepted" >&2
expect_code 124 "$(code_of run_gate hang "$tmp_dir/run-hang" --timeout-seconds 2)" "runner: TERM-ignoring child is killed"
if grep -qE '^TIMEOUT term_grace_s=1[0-9] killed=1$' "$tmp_dir/run-hang/gate.log"; then
pass "runner: TERM grace honoured before KILL"
else
fail "runner: timeout sequence: $(tr '\n' ' ' <"$tmp_dir/run-hang/gate.log" 2>/dev/null)"
fi
[ "$(report_status "$tmp_dir/run-hang")" = "FAIL" ] && pass "runner: timeout sealed FAIL" || fail "runner: timeout not sealed FAIL"
expect_code 1 "$(code_of run_gate fifo-holder "$tmp_dir/run-fifo")" "runner: grandchild holding the log pipe"
set +e
MT_FAKE_SCENARIO=exit3 bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$tmp_dir/run-pipe" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" 2>&1 | cat >/dev/null
piped=$?
set -e
expect_code 1 "$piped" "runner: exit code survives | cat under pipefail"
mkdir -p "$tmp_dir/run-nocreds"
code="$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" \
--config "$tmp_dir/scenario.local.json" --output "$tmp_dir/run-nocreds" --assets "$tmp_dir/assets" \
--serverlist "$tmp_dir/serverlist.txt" </dev/null)"
expect_code 2 "$code" "runner: no credentials and no TTY"
[ ! -e "$tmp_dir/run-nocreds/client.log" ] && pass "runner: client not started without credentials" || fail "runner: client started without credentials"
expect_code 2 "$(code_of run_gate pass "$tmp_dir/run-pass")" "runner: refuses a non-empty output directory"
expect_code 2 "$(code_of env MT_PLAYABLE_REQUIRED_ARCH=x86_64 bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-arch" "$tmp_dir/assets" "$tmp_dir/serverlist.txt")" "runner: missing required architecture"
printf '0\t127.0.0.1\t1\t127.0.0.1\t1\t1\t10\t0\n' >"$tmp_dir/serverlist-drift.txt"
expect_code 2 "$(code_of env bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-drift" "$tmp_dir/assets" "$tmp_dir/serverlist-drift.txt")" "runner: config address differs from serverlist"
[ ! -e "$tmp_dir/run-drift/client.log" ] && pass "runner: client not started on address drift" || fail "runner: client started on address drift"
cp -R "$app" "$tmp_dir/Tampered.app"
printf 'tampered\n' >>"$tmp_dir/Tampered.app/Contents/Resources/mtgodot-poc.pck"
expect_code 2 "$(code_of env bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$tmp_dir/Tampered.app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-tampered" "$tmp_dir/assets" "$tmp_dir/serverlist.txt")" "runner: broken signature"
# ---------- release aggregation: explicit run list only ----------
release="$tmp_dir/release"
mkdir -p "$release"
cp -R "$tmp_dir/run-pass" "$release/run-1"
cp -R "$tmp_dir/run-pass-2" "$release/run-2"
cp -R "$tmp_dir/run-exit3" "$release/run-bad"
bash script/playable_build_info.sh "$app" >"$tmp_dir/candidate.json"
write_manifest() { # required runs-json [candidate-override-js]
node -e '
const fs = require("node:fs");
const [dir, required, runs, candidateFile, tweak] = process.argv.slice(1);
const candidate = JSON.parse(fs.readFileSync(candidateFile, "utf8"));
if (tweak) eval(tweak);
const list = JSON.parse(runs).map((name) => ({ run_id: JSON.parse(fs.readFileSync(`${dir}/${name}/report.json`, "utf8")).run_id, suite: "playable", path: name }));
fs.writeFileSync(`${dir}/release-manifest.json`, JSON.stringify({ schema_version: 1, candidate, required_runs: { playable: Number(required) }, runs: list }));
' "$release" "$1" "$2" "$tmp_dir/candidate.json" "${3:-}"
}
release_code() { code_of quiet node script/validate_playable_report.mjs --release-dir "$release"; }
write_manifest 2 '["run-1","run-2"]'
expect_code 0 "$(release_code)" "release: two listed PASS runs"
write_manifest 2 '["run-1"]'
expect_code 1 "$(release_code)" "release: unlisted PASS runs are never counted"
write_manifest 2 '["run-1","run-2","run-bad"]'
expect_code 1 "$(release_code)" "release: a listed FAIL run fails the release"
write_manifest 2 '["run-1","run-2"]' 'candidate.pck_sha256 = "e".repeat(64)'
expect_code 1 "$(release_code)" "release: runs from another candidate"
write_manifest 2 '["run-1","run-1"]'
expect_code 1 "$(release_code)" "release: the same run listed twice"
node -e 'const fs=require("fs");const f=process.argv[1];const m=JSON.parse(fs.readFileSync(f));m.runs[0].path="../run-pass";fs.writeFileSync(f,JSON.stringify(m));' "$release/release-manifest.json"
expect_code 1 "$(release_code)" "release: run path escapes the release directory"
mkdir -p "$release/run-blocked"
printf 'BLOCKED 缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端\n' >"$release/run-blocked/gate.log"
node -e 'const fs=require("fs");const f=process.argv[1];const m=JSON.parse(fs.readFileSync(f));m.runs=[m.runs[1],{run_id:"run-blocked-unsealed",suite:"playable",path:"run-blocked"}];m.required_runs={playable:2};fs.writeFileSync(f,JSON.stringify(m));' "$release/release-manifest.json"
expect_code 2 "$(release_code)" "release: precondition-blocked run is BLOCKED, not PASS"
# ---------- batch entry: manifest written from the runs it just made ----------
expect_code 0 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$tmp_dir/batch" --suite playable --repeat 2 --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "batch: two runs aggregate to PASS"
batch_runs="$(node -e 'const m=require(process.argv[1]);process.stdout.write(m.runs.map((r)=>r.path).join(","))' "$tmp_dir/batch/release-manifest.json")"
[ "$batch_runs" = "run-1,run-2" ] && pass "batch: manifest lists exactly its own runs" || fail "batch: manifest runs $batch_runs"
expect_code 1 "$(code_of quiet env MT_FAKE_SCENARIO=rid bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$tmp_dir/batch-rid" --suite playable --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "batch: a failing run fails the batch"
expect_code 2 "$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$tmp_dir/batch-nocreds" </dev/null)" "batch: no credentials and no TTY"
# ---------- MAP-02 forest render runner (offline, same exit gate) ----------
run_forest() { # scenario output [extra args...]
local scenario="$1" out="$2"
shift 2
MT_FAKE_SCENARIO="$scenario" bash script/forest_map_render_test.sh --app "$app" --maps "$map_key" --output "$out" "$@" >"$out.stdout" 2>&1
}
expect_code 0 "$(code_of run_forest no-creds "$tmp_dir/forest-pass")" "forest: PASS without credentials in the child"
forest_cases="$(node -e 'process.stdout.write(require(process.argv[1]).cases.join(","))' "$tmp_dir/forest-pass/required-cases.json")"
[ "$forest_cases" = "MAP-FOREST-CONFIG,MAP-FOREST-LOAD-metin2_map_trent,MAP-FOREST-VP-metin2_map_trent-flat,MAP-FOREST-VP-metin2_map_trent-slope,MAP-FOREST-VP-metin2_map_trent-dense,MAP-FOREST-VP-metin2_map_trent-warp,MAP-FOREST-MOTION-metin2_map_trent" ] \
&& pass "forest: required cases follow required_case_ids()" || fail "forest: required cases $forest_cases"
grep -q "人工签核" "$tmp_dir/forest-pass.stdout" && pass "forest: manual screenshot sign-off stays pending" || fail "forest: sign-off reminder missing"
expect_code 2 "$(code_of run_forest blocked "$tmp_dir/forest-blocked")" "forest: BLOCKED viewpoints are not releasable"
expect_code 1 "$(code_of run_forest missing-case "$tmp_dir/forest-missing-case")" "forest: missing required case"
expect_code 1 "$(code_of run_forest leak "$tmp_dir/forest-leak")" "forest: leak warning after PASS"
expect_code 124 "$(code_of run_forest hang "$tmp_dir/forest-hang" --timeout-seconds 2)" "forest: TERM-ignoring child is killed"
mkdir -p "$tmp_dir/forest-nomap"
expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/forest_map_render_test.sh --app "$app" --output "$tmp_dir/forest-nomap")" "forest: no map key"
[ ! -e "$tmp_dir/forest-nomap/client.log" ] && pass "forest: client not started without a map" || fail "forest: client started without a map"
expect_code 2 "$(code_of run_forest pass "$tmp_dir/forest-races" --races 2301,abc)" "forest: invalid race list"
expect_code 2 "$(code_of run_forest pass "$tmp_dir/forest-pass")" "forest: refuses a non-empty output directory"
# ---------- STB-01 soak runner (offline; fake client, no real 2 h run) ----------
soak_config() { # output-file server-port [faults-js]
node -e '
const fs = require("node:fs");
const [base, out, port, faults] = process.argv.slice(1);
const c = JSON.parse(fs.readFileSync(base, "utf8"));
c.timeout_seconds = 8100;
c.server.auth_port = Number(port); c.server.game_port = Number(port);
c.soak = { duration_seconds: 7200, rest_seconds: 30, warmup_rounds: 1, min_rounds: 10, reconnects: 10, exits: 10,
resolutions: { sizes: [[1280, 720], [1440, 900], [1600, 1000]], switches_per_size: 10 },
warp: { status: "unconfirmed" }, faults: { status: "unconfirmed" } };
if (faults) c.soak.faults = JSON.parse(faults);
fs.writeFileSync(out, JSON.stringify(c, null, 2));
' "$tmp_dir/scenario.local.json" "$1" "$2" "${3:-}"
}
soak_config "$tmp_dir/soak.local.json" "$port"
run_soak_gate() { # scenario output config serverlist [extra args...]
local scenario="$1" out="$2" config="$3" list="$4"
shift 4
MT_FAKE_SCENARIO="$scenario" bash script/run_client_gate.sh --app "$app" --config "$config" --output "$out" \
--assets "$tmp_dir/assets" --serverlist "$list" --suite soak --allow-gameplay "$@" >"$out.stdout" 2>&1
}
mkdir -p "$tmp_dir/soak-short"
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-short" "$tmp_dir/soak.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 900)" "soak runner: timeout below duration+900"
[ ! -e "$tmp_dir/soak-short/client.log" ] && pass "soak runner: client not started with a short timeout" || fail "soak runner: client started with a short timeout"
mkdir -p "$tmp_dir/soak-noblock"
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-noblock" "$tmp_dir/scenario.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 8100)" "soak runner: scenario without a soak block"
[ ! -e "$tmp_dir/soak-noblock/client.log" ] && pass "soak runner: client not started without a soak block" || fail "soak runner: client started without a soak block"
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-run" "$tmp_dir/soak.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 8100)" "soak runner: fake client without soak rounds is BLOCKED, not PASS"
soak_seal="$(node -e '
const fs = require("node:fs");
const dir = process.argv[1];
const r = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8"));
const memory = (r.runner_cases || []).find((c) => c.id === "STB-MEMORY-01");
const rows = fs.readFileSync(`${dir}/rss.jsonl`, "utf8").trim().split("\n").filter(Boolean).length;
process.stdout.write([r.status, memory ? memory.status : "none", rows > 0 ? "rss" : "no-rss", r.exit_gate.checked].join(","));
' "$tmp_dir/soak-run" 2>/dev/null || echo unreadable)"
[ "$soak_seal" = "BLOCKED,BLOCKED,rss,true" ] && pass "soak runner: RSS sampled for the child and STB-MEMORY-01 sealed" || fail "soak runner: seal $soak_seal"
[ ! -e "$tmp_dir/soak-run/fault-proxy.jsonl" ] && pass "soak runner: no fault proxy while faults are unconfirmed" || fail "soak runner: proxy started for unconfirmed faults"
# Confirmed faults: client points at a loopback proxy port, proxy forwards to the local listener.
proxy_port="$(node -e 'const s=require("node:net").createServer();s.listen(0,"127.0.0.1",()=>{const p=s.address().port;s.close(()=>process.stdout.write(String(p)));})')"
printf 'fixture\t127.0.0.1\t%s\t127.0.0.1\t%s\t1\t10\t0\n' "$proxy_port" "$proxy_port" >"$tmp_dir/serverlist-proxy.txt"
soak_config "$tmp_dir/soak-proxy.local.json" "$proxy_port" "{\"status\":\"confirmed\",\"mode\":\"local_proxy\",\"per_type\":1,\"unreachable_seconds\":5,\"upstream\":{\"auth_host\":\"127.0.0.1\",\"auth_port\":$port,\"game_host\":\"127.0.0.1\",\"game_port\":$port}}"
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-proxy" "$tmp_dir/soak-proxy.local.json" "$tmp_dir/serverlist-proxy.txt" --timeout-seconds 8100)" "soak runner: confirmed faults run through the loopback proxy"
proxy_actions="$(node -e 'const rows=require("node:fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map((l)=>JSON.parse(l).action);process.stdout.write(String(rows[0]==="listening"&&rows.at(-1)==="stopped"))' "$tmp_dir/soak-proxy/fault-proxy.jsonl" 2>/dev/null || echo false)"
[ "$proxy_actions" = "true" ] && pass "soak runner: proxy listened before the client and stopped after it" || fail "soak runner: proxy log incomplete"
proxy_pid="$(sed -n 's/^FAULT PROXY pid=\([0-9]*\) .*/\1/p' "$tmp_dir/soak-proxy/gate.log")"
if [ -n "$proxy_pid" ] && ! kill -0 "$proxy_pid" 2>/dev/null; then pass "soak runner: proxy process is gone after the run"; else fail "soak runner: proxy pid [$proxy_pid] still alive or unknown"; fi
proxy_playable() { # output: an exit run (suite playable) with the same confirmed-faults config still reaches the server
MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/soak-proxy.local.json" --output "$1" \
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist-proxy.txt" --allow-gameplay >"$1.stdout" 2>&1
}
expect_code 0 "$(code_of proxy_playable "$tmp_dir/soak-proxy-exit")" "soak runner: playable exit run behind the confirmed proxy"
grep -q '"action":"stopped"' "$tmp_dir/soak-proxy-exit/fault-proxy.jsonl" 2>/dev/null && [ ! -e "$tmp_dir/soak-proxy-exit/rss.jsonl" ] \
&& pass "soak runner: exit run forwards through the proxy without soak sampling" || fail "soak runner: exit run proxy/sampler state"
node -e 'const fs=require("fs");const c=JSON.parse(fs.readFileSync(process.argv[1]));c.timeout_seconds=900;fs.writeFileSync(process.argv[2],JSON.stringify(c));' "$tmp_dir/soak.local.json" "$tmp_dir/soak-short-timeout.local.json"
expect_code 0 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/soak-short-timeout.local.json" \
--output "$tmp_dir/soak-block-playable" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "soak runner: playable suite ignores the soak-only timeout margin"
bad_soak="$tmp_dir/soak-bad-playable.local.json"
node -e 'const fs=require("fs");const c=JSON.parse(fs.readFileSync(process.argv[1]));c.soak.faults.upstream.auth_port=70000;fs.writeFileSync(process.argv[2],JSON.stringify(c));' "$tmp_dir/soak-proxy.local.json" "$bad_soak"
mkdir -p "$tmp_dir/soak-bad-playable"
expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$bad_soak" --output "$tmp_dir/soak-bad-playable" \
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist-proxy.txt")" "soak runner: an invalid soak block is rejected for every suite"
grep -q "soak.faults.upstream.auth_port" "$tmp_dir/soak-bad-playable/config.log" 2>/dev/null && [ ! -e "$tmp_dir/soak-bad-playable/client.log" ] \
&& pass "soak runner: config gate names the invalid upstream before any client" || fail "soak runner: invalid soak block not caught by the config gate"
mkdir -p "$tmp_dir/soak-proxy-down"
soak_config "$tmp_dir/soak-proxy-down.local.json" "$proxy_port" "{\"status\":\"confirmed\",\"mode\":\"local_proxy\",\"per_type\":1,\"unreachable_seconds\":5,\"upstream\":{\"auth_host\":\"127.0.0.1\",\"auth_port\":1,\"game_host\":\"127.0.0.1\",\"game_port\":1}}"
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-proxy-down" "$tmp_dir/soak-proxy-down.local.json" "$tmp_dir/serverlist-proxy.txt" --timeout-seconds 8100)" "soak runner: unreachable fault-proxy upstream"
[ ! -e "$tmp_dir/soak-proxy-down/client.log" ] && pass "soak runner: client not started when the upstream is down" || fail "soak runner: client started with upstream down"
# ---------- STB-01 public entry: script/playable_soak.sh ----------
cancel_gate script/playable_soak.sh "$tmp_dir/soak-cancel" --config "$tmp_dir/soak.local.json" \
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay
run_soak() { # scenario output [extra args...]
local scenario="$1" out="$2"
shift 2
MT_FAKE_SCENARIO="$scenario" bash script/playable_soak.sh --app "$app" --config "$tmp_dir/soak.local.json" --output "$out" \
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" "$@" </dev/null >"$out.stdout" 2>&1
}
expect_code 0 "$(code_of quiet bash script/playable_soak.sh --help)" "soak: --help"
expect_code 2 "$(code_of quiet bash script/playable_soak.sh --app "$app" </dev/null)" "soak: no config"
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-nogameplay")" "soak: refuses to start without --allow-gameplay"
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-duration" --allow-gameplay --duration-seconds 60)" "soak: --duration-seconds must match the config"
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-exits" --allow-gameplay --exits 9)" "soak: fewer than 10 exit runs"
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-timeout" --allow-gameplay --timeout-seconds 7300)" "soak: timeout below duration+900"
expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=soak-pass bash script/playable_soak.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$tmp_dir/soak-noblock-entry" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay </dev/null)" "soak: config without a soak block"
for dir in soak-nogameplay soak-duration soak-exits soak-timeout soak-noblock-entry; do
if [ -n "$(find "$tmp_dir/$dir" -name client.log 2>/dev/null)" ]; then fail "soak: $dir started a client"; fi
done
expect_code 2 "$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD MT_FAKE_SCENARIO=soak-pass bash script/playable_soak.sh --app "$app" \
--config "$tmp_dir/soak.local.json" --output "$tmp_dir/soak-nocreds" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay </dev/null)" "soak: no credentials and no TTY"
expect_code 1 "$(code_of run_soak rid "$tmp_dir/soak-exitfail" --allow-gameplay)" "soak: a failing exit run fails the release"
[ ! -e "$tmp_dir/soak-exitfail/soak-1/client.log" ] && grep -q "^BLOCKED not started: exit-1" "$tmp_dir/soak-exitfail/soak-1/gate.log" 2>/dev/null \
&& pass "soak: 2 h soak not started after a failed exit run" || fail "soak: soak run started after a failed exit run"
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-batch" --allow-gameplay)" "soak: fake batch stays BLOCKED without real soak rounds"
soak_runs="$(node -e 'const m=require(process.argv[1]);process.stdout.write(m.runs.map((r)=>`${r.path}:${r.suite}`).join(",")+"|"+JSON.stringify(m.required_runs))' "$tmp_dir/soak-batch/release-manifest.json" 2>/dev/null || echo missing)"
expected_runs="$(for i in 1 2 3 4 5 6 7 8 9 10; do printf 'exit-%s:playable,' "$i"; done)soak-1:soak|{\"playable\":10,\"soak\":1}"
[ "$soak_runs" = "$expected_runs" ] && pass "soak: manifest lists 10 exit runs and one soak run" || fail "soak: manifest $soak_runs"
exit_passes="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.counts.playable.passed)+"/"+r.status)' "$tmp_dir/soak-batch/release-report.json" 2>/dev/null || echo missing)"
[ "$exit_passes" = "10/BLOCKED" ] && pass "soak: 10 independent exit runs PASS, release BLOCKED by memory evidence" || fail "soak: release $exit_passes"
if grep -rqF -e "$MT_ACCOUNT" -e "$MT_PASSWORD" "$tmp_dir/soak-batch" "$tmp_dir/soak-batch.stdout"; then
fail "soak: credential literal reached the batch directory"
else
pass "soak: no credential literal in the batch directory"
fi
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-batch" --allow-gameplay)" "soak: refuses a non-empty output directory"
if [ "$failures" -ne 0 ]; then
echo "playable_gate_test: FAIL ($failures)" >&2
exit 1
fi
echo "playable_gate_test: PASS"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Sourced by runners. Only signal children whose PIDs this runner still owns.
# Clear each PID immediately after wait, so EXIT cannot signal a reused PID.
child_pid=""
redactor_pid=""
sampler_pid=""
proxy_pid=""
cleanup_playable_processes() {
local status=$? pid round alive
trap - EXIT
trap '' HUP INT TERM
for pid in "$child_pid" "$redactor_pid" "$sampler_pid" "$proxy_pid"; do
if [ -n "$pid" ]; then kill -TERM "$pid" 2>/dev/null || true; fi
done
for ((round=0; round<${process_cleanup_grace_seconds:-10}; round++)); do
alive=0
for pid in "$child_pid" "$redactor_pid" "$sampler_pid" "$proxy_pid"; do
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; fi
done
[ "$alive" -eq 1 ] || break
sleep 1
done
for pid in "$child_pid" "$redactor_pid" "$sampler_pid" "$proxy_pid"; do
if [ -n "$pid" ]; then
kill -KILL "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
done
if [ -n "${fifo:-}" ] && [ -p "$fifo" ]; then rm -f "$fifo"; fi
return "$status"
}
trap cleanup_playable_processes EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
// Release aggregation must reject the intermediate report before the RSS seal.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-playable-release-'));
const write = (name, value) => fs.writeFileSync(path.join(root, name), JSON.stringify(value));
const fixture = JSON.parse(fs.readFileSync(path.join(repo, 'test/playable/report.valid.json'), 'utf8'));
const manifest = { schema_version: 1, candidate: fixture.build, required_runs: { soak: 1 },
runs: [{ run_id: fixture.run_id, suite: 'soak', path: 'run-1' }] };
const report = { ...fixture, suite: 'soak', exit_gate: { checked: true, process_code: 0, timed_out: false, errors: [] } };
const baseline = process.argv.includes('--baseline')
? spawnSync('git', ['show', 'f39a55fd:script/validate_playable_report.mjs'], { cwd: repo, encoding: 'utf8' }).stdout : null;
function check(expected, label) {
const args = baseline ? ['--input-type=module', '-'] : [path.join(repo, 'script/validate_playable_report.mjs')];
const result = spawnSync(process.execPath, [...args, '--release-dir', root], { input: baseline, encoding: 'utf8' });
assert.equal(result.status, expected, `${label}: ${result.stdout} ${result.stderr}`);
assert.equal(JSON.parse(fs.readFileSync(path.join(root, 'release-report.json'))).status, expected === 0 ? 'PASS' : 'FAIL');
console.log(`ok - ${label}`);
}
try {
fs.mkdirSync(path.join(root, 'run-1'));
write('release-manifest.json', manifest);
write('run-1/report.json', report);
check(1, 'soak PASS without external memory seal is rejected');
report.runner_cases = [{ id: 'STB-MEMORY-01', status: 'PASS' }];
write('run-1/report.json', report);
check(1, 'memory case alone cannot replace the verdict');
report.memory = { verdict: { status: 'FAIL' } };
write('run-1/report.json', report);
check(1, 'contradictory memory verdict is rejected');
report.memory.verdict.status = 'PASS';
write('run-1/report.json', report);
check(0, 'clean exit and external memory PASS can be aggregated');
write('run-1/report.json', null);
check(1, 'null run report produces a sealed release failure');
write('release-manifest.json', null);
check(1, 'null manifest produces a sealed release failure');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
# STB-01 public entry: 2 h wall-clock soak plus independent normal-exit runs.
#
# Layout under --output (must be empty):
# exit-<n>/ N independent client processes, suite playable, each through
# run_client_gate.sh (normal quit, exit 0, no leak/RID warnings)
# soak-1/ one client process, suite soak: move/combat/pickup rounds until the
# wall-clock duration, active reconnects, resolution switches, and —
# only when the scenario confirms them — portal warps and real
# transport faults through the 127.0.0.1 fault proxy; RSS is sampled
# for that child PID and sealed as STB-MEMORY-01
# release-manifest.json listing exactly these runs; aggregated with
# validate_playable_report.mjs --release-dir (no historical reports are scanned).
#
# Exit runs go first; if one is not PASS the remaining runs (including the 2 h soak)
# are not started and are recorded as BLOCKED so the release cannot pass.
# Credentials only from MT_ACCOUNT/MT_PASSWORD or one hidden TTY prompt.
# Do not add `set -x` to this script. Never pkill; never touch routes/firewall/caches.
#
# exit: 0 PASS, 1 FAIL, 2 BLOCKED (config/precondition/unconfirmed environment), 124 timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
repo="$PWD"
process_cleanup_grace_seconds=15 # Allow the child runner's 10-second cleanup to finish.
source script/playable_process_cleanup.sh
app="${MT_PLAYABLE_APP:-}"
config=""
output=""
duration=""
exits=""
timeout=""
exit_timeout="900"
allow=0
assets=""
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
usage() {
cat <<'EOF'
用法: script/playable_soak.sh --app APP --config CONFIG --allow-gameplay [选项]
--app APP 候选导出包(所有 run 必须同一哈希)
--config CONFIG 场景 JSON(含 soak 块;不含凭据)
--output DIR 本批次根目录,必须不存在或为空(默认 build/playable/soak-<时间>-<pid>
--duration-seconds N 墙钟时长,必须等于配置 soak.duration_seconds(默认取配置,>= 7200
--exits N 独立正常退出运行次数(默认配置 soak.exits>= 10
--timeout-seconds N soak 进程墙钟超时(默认 duration+900,不得更小)
--exit-timeout-seconds N 每个退出运行的墙钟超时,默认 900
--assets DIR 资源预检根目录(透传 run_client_gate.sh
--serverlist FILE 测试模式 serverlist 覆盖(透传)
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求(不给则只校验配置,返回 2,不启动客户端)
环境变量:
MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入一次)
说明:
切图(soak.warp)和真实断网(soak.faults)须由环境负责人确认后在配置里标 confirmed;
未确认时对应用例为 BLOCKED,不用主动重连或直接传送替代。
退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时
EOF
}
blocked() { echo "PLAYABLE SOAK: BLOCKED $*" >&2; exit 2; }
positive() { [[ "$1" =~ ^[1-9][0-9]*$ ]]; }
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app="${2:-}"; shift 2 ;;
--config) config="${2:-}"; shift 2 ;;
--output) output="${2:-}"; shift 2 ;;
--duration-seconds) duration="${2:-}"; shift 2 ;;
--exits) exits="${2:-}"; shift 2 ;;
--timeout-seconds) timeout="${2:-}"; shift 2 ;;
--exit-timeout-seconds) exit_timeout="${2:-}"; shift 2 ;;
--assets) assets="${2:-}"; shift 2 ;;
--serverlist) serverlist="${2:-}"; shift 2 ;;
--allow-gameplay) allow=1; shift ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$app" ] && [ -d "$app" ] || blocked "需要 --app 指向候选导出包"
[ -n "$config" ] && [ -f "$config" ] || blocked "需要 --config CONFIG"
config="$(cd "$(dirname "$config")" && pwd)/$(basename "$config")"
for pair in "duration-seconds:$duration" "exits:$exits" "timeout-seconds:$timeout"; do
value="${pair#*:}"
if [ -n "$value" ] && ! positive "$value"; then blocked "--${pair%%:*} 必须是正整数"; fi
done
positive "$exit_timeout" || blocked "--exit-timeout-seconds 必须是正整数"
# Config validation before any prompt or process: the same gate the runner uses.
if [ -z "$assets" ]; then
if [ -d "$app/Contents/Resources/assets" ]; then assets="$app/Contents/Resources/assets"; else assets="$repo/assets"; fi
fi
if ! config_log="$(env MT_PLAYABLE_VALIDATE_CONFIG="$config" MT_PLAYABLE_SUITE=soak MT_PLAYABLE_ASSETS="$assets" \
MT_PLAYABLE_SERVERLIST="$serverlist" "${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd 2>&1)"; then
printf '%s\n' "$config_log" | grep '^CONFIG:' >&2 || true
blocked "配置/资源预检失败(suite soak"
fi
read -r cfg_duration cfg_exits <<<"$(node -e '
const s = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).soak;
process.stdout.write(`${s.duration_seconds} ${s.exits}`);
' "$config")"
if [ -z "$duration" ]; then duration="$cfg_duration"; fi
[ "$duration" -eq "$cfg_duration" ] || blocked "--duration-seconds $duration 与配置 soak.duration_seconds $cfg_duration 不一致;请改配置而不是覆盖"
if [ -z "$exits" ]; then exits="$cfg_exits"; fi
[ "$exits" -ge 10 ] || blocked "--exits 至少 10"
minimum_timeout=$((duration + 900))
if [ -z "$timeout" ]; then timeout="$minimum_timeout"; fi
[ "$timeout" -ge "$minimum_timeout" ] || blocked "--timeout-seconds 不得小于 duration+900${minimum_timeout}"
# Without explicit consent only the configuration is checked; no client, no gameplay request.
[ "$allow" -eq 1 ] || blocked "配置校验通过;soak 需要 --allow-gameplay(专用测试账号上的移动/战斗/拾取),未启动客户端"
root="$output"
if [ -z "$root" ]; then root="$repo/build/playable/soak-$(date +%Y%m%d-%H%M%S)-$$"; fi
if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
blocked "输出目录已有内容,拒绝混入历史运行:$root"
fi
mkdir -p "$root"
root="$(cd "$root" && pwd)"
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
if [ -t 0 ]; then
if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi
if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi
fi
fi
[ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端"
export MT_ACCOUNT MT_PASSWORD
bash script/playable_build_info.sh "$app" >"$root/candidate.json"
runs_json="[]"
timed_out=0
stop_reason=""
list_run() { # name suite
runs_json="$(node -e '
const fs = require("node:fs");
const [runs, dir, name, suite] = process.argv.slice(1);
let runId = `${name}-unsealed`;
try { runId = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")).run_id || runId; } catch {}
const list = JSON.parse(runs);
list.push({ run_id: runId, suite, path: name });
process.stdout.write(JSON.stringify(list));
' "$runs_json" "$root/$1" "$1" "$2")"
}
run_one() { # name suite timeout
local name="$1" run_suite="$2" run_timeout="$3" code
if [ -n "$stop_reason" ]; then
mkdir -p "$root/$name"
echo "BLOCKED not started: $stop_reason" >"$root/$name/gate.log"
list_run "$name" "$run_suite"
return
fi
local args=(--app "$app" --config "$config" --output "$root/$name" --suite "$run_suite" --timeout-seconds "$run_timeout" --allow-gameplay)
if [ -n "$assets" ]; then args+=(--assets "$assets"); fi
if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi
set +e
bash script/run_client_gate.sh "${args[@]}" </dev/null &
child_pid=$!
wait "$child_pid"
code=$?
child_pid=""
set -e
if [ "$code" -eq 124 ]; then timed_out=1; fi
if [ "$code" -ne 0 ] && [ "$run_suite" = "playable" ]; then stop_reason="$name exit $code"; fi
list_run "$name" "$run_suite"
}
for index in $(seq 1 "$exits"); do
run_one "exit-$index" playable "$exit_timeout"
done
run_one soak-1 soak "$timeout"
node -e '
const fs = require("node:fs");
const [root, exits, runs] = process.argv.slice(1);
const candidate = JSON.parse(fs.readFileSync(`${root}/candidate.json`, "utf8"));
fs.writeFileSync(`${root}/release-manifest.json`, JSON.stringify({
schema_version: 1, candidate, required_runs: { playable: Number(exits), soak: 1 }, runs: JSON.parse(runs),
}, null, 2) + "\n");
' "$root" "$exits" "$runs_json"
set +e
node script/validate_playable_report.mjs --release-dir "$root"
release_code=$?
set -e
if [ -f "$root/soak-1/report.json" ]; then
node -e '
const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
for (const c of [...(r.cases || []).filter((c) => c.id.startsWith("STB-")), ...(r.runner_cases || [])]) console.log(` ${c.id} ${c.status} ${c.reason || ""}`);
' "$root/soak-1/report.json" || true
fi
if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE SOAK: TIMEOUT root=$root" >&2; exit 124; fi
case "$release_code" in
0) echo "PLAYABLE SOAK: PASS exits=$exits duration=${duration}s root=$root" ;;
2) echo "PLAYABLE SOAK: BLOCKED root=$root" >&2 ;;
*) echo "PLAYABLE SOAK: FAIL root=$root" >&2; release_code=1 ;;
esac
exit "$release_code"
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env node
/* STB-01 §9.1 external memory probe and leak gate.
*
* playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000]
* Appends {wall_ms, rss_kib} for exactly one PID (the runner's own child) via
* `ps -o stat=,rss= -p PID` once per interval and exits when that PID is gone or a zombie. It
* never signals the PID. A failed read is written as rss_kib null, not 0.
*
* playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl
* [--warmup-rounds 1] [--min-rounds 10]
* Reads the client's soak_rest start/end events (payload.round, payload.wall_ms),
* takes the RSS low water inside each >=30 s rest window, applies the leak rule
* and writes report.memory + runner_cases[STB-MEMORY-01] back into the sealed
* report. The verdict can only downgrade the report status.
*
* Leak rule: after warm-up, FAIL when the low water grew in >=5 consecutive rounds
* AND median(last 5) - median(first 5) > max(50 MiB, 5% of median(first 5)).
* Below the threshold is not proof that there is no long-term leak.
*
* exit: 0 PASS, 1 FAIL, 2 BLOCKED or usage error. */
import fs from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const REST_MIN_MS = 30000;
const STREAK = 5;
const ABS_THRESHOLD_KIB = 50 * 1024;
const PCT_THRESHOLD = 0.05;
const NOTE = '阈值以下不能证明长期无泄漏;GPU 内存不可得(null/unavailable),不据此判断显存。';
const CASE_ID = 'STB-MEMORY-01';
const RANK = { PASS: 0, BLOCKED: 1, FAIL: 2 };
export function median(values) {
const sorted = [...values].sort((a, b) => a - b);
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function parseRss(text) {
const samples = [];
let badLines = 0;
for (const line of String(text).split('\n')) {
if (line.trim() === '') continue;
try {
const row = JSON.parse(line);
if (!Number.isFinite(row.wall_ms) || !(row.rss_kib === null || Number.isInteger(row.rss_kib))) badLines += 1;
else samples.push({ wall_ms: row.wall_ms, rss_kib: row.rss_kib });
} catch { badLines += 1; }
}
return { samples, bad_lines: badLines };
}
/** Pair soak_rest start/end events per round. Unpaired or reordered events are errors. */
export function restWindows(eventsText) {
const open = new Map();
const windows = [];
const errors = [];
for (const line of String(eventsText).split('\n')) {
if (line.trim() === '') continue;
let event;
try { event = JSON.parse(line); } catch { continue; }
if (event?.kind !== 'soak_rest') continue;
const { round, state, wall_ms: wallMs } = event.payload ?? {};
if (!Number.isInteger(round) || !Number.isFinite(wallMs)) { errors.push('soak_rest event without integer round/wall_ms'); continue; }
if (state === 'start') {
if (open.has(round)) errors.push(`round ${round}: rest started twice`);
open.set(round, wallMs);
} else if (state === 'end') {
if (!open.has(round)) { errors.push(`round ${round}: rest ended without a start`); continue; }
const start = open.get(round);
open.delete(round);
if (wallMs < start) errors.push(`round ${round}: rest ends before it starts`);
else windows.push({ round, start_ms: start, end_ms: wallMs, rest_ms: wallMs - start });
}
}
for (const round of open.keys()) errors.push(`round ${round}: rest never ended`);
windows.sort((a, b) => a.round - b.round);
return { windows, errors };
}
export function roundLowWaters(windows, samples) {
return windows.map((window) => {
const inside = samples.filter((s) => s.wall_ms >= window.start_ms && s.wall_ms <= window.end_ms && Number.isInteger(s.rss_kib));
// At least one sample per two seconds of rest; a sparse window is not a measured low water.
const needed = Math.max(1, Math.floor(window.rest_ms / 2000));
return {
round: window.round,
rest_ms: window.rest_ms,
samples: inside.length,
low_water_kib: inside.length >= needed ? Math.min(...inside.map((s) => s.rss_kib)) : null,
};
});
}
export function leakVerdict(lowWaters, { warmupRounds = 1, minRounds = 10 } = {}) {
const verdict = {
status: 'BLOCKED', reason: '', unit: 'KiB', warmup_rounds: warmupRounds, rounds_total: lowWaters.length, rounds_analysed: 0,
first5_median_kib: null, last5_median_kib: null, growth_kib: null, threshold_kib: null, max_growth_streak: 0,
rule: `FAIL iff >=${STREAK} consecutive growing rounds AND last5 median - first5 median > max(50MiB, 5%)`,
warnings: [], note: NOTE,
};
const short = lowWaters.find((row) => row.rest_ms < REST_MIN_MS);
if (short) return { ...verdict, status: 'FAIL', reason: `round ${short.round}: rest ${short.rest_ms} ms is shorter than 30 s` };
const rows = lowWaters.slice(warmupRounds);
verdict.rounds_analysed = rows.length;
if (rows.length < minRounds) return { ...verdict, reason: `need at least ${minRounds} rounds after warm-up, got ${rows.length}` };
const missing = rows.find((row) => row.low_water_kib === null);
if (missing) return { ...verdict, reason: `round ${missing.round}: no RSS low water (sampler gap)` };
const values = rows.map((row) => row.low_water_kib);
let streak = 0;
for (let i = 1; i < values.length; i += 1) {
streak = values[i] > values[i - 1] ? streak + 1 : 0;
verdict.max_growth_streak = Math.max(verdict.max_growth_streak, streak);
}
const first = Math.round(median(values.slice(0, STREAK)));
const last = Math.round(median(values.slice(-STREAK)));
verdict.first5_median_kib = first;
verdict.last5_median_kib = last;
verdict.growth_kib = last - first;
verdict.threshold_kib = Math.max(ABS_THRESHOLD_KIB, Math.ceil(PCT_THRESHOLD * first));
const overThreshold = verdict.growth_kib > verdict.threshold_kib;
const streakHit = verdict.max_growth_streak >= STREAK;
if (overThreshold && streakHit) {
verdict.status = 'FAIL';
verdict.reason = `low water grew ${verdict.max_growth_streak} rounds in a row and +${verdict.growth_kib} KiB > ${verdict.threshold_kib} KiB; analyse before release`;
return verdict;
}
if (overThreshold) verdict.warnings.push(`median growth +${verdict.growth_kib} KiB exceeds the threshold without ${STREAK} consecutive growing rounds; needs analysis`);
verdict.status = 'PASS';
verdict.reason = 'no leak signal at the gate threshold';
return verdict;
}
export function sealMemory(report, { eventsText, rssText, warmupRounds = 1, minRounds = 10 }) {
const rss = parseRss(rssText ?? '');
const rest = restWindows(eventsText ?? '');
const rounds = roundLowWaters(rest.windows, rss.samples);
let verdict = leakVerdict(rounds, { warmupRounds, minRounds });
if (rest.errors.length > 0) verdict = { ...verdict, status: 'FAIL', reason: `rest events: ${rest.errors.slice(0, 5).join('; ')}` };
else if (rest.windows.length === 0) verdict = { ...verdict, status: 'BLOCKED', reason: 'no soak_rest windows in events' };
// The client's own MEMORY_STATIC snapshots stay separate from the external RSS.
const godot = report.memory?.godot ?? report.memory ?? null;
report.memory = {
sampler: 'external ps -o stat=,rss= on the runner-held child PID, 1 Hz',
unit: 'KiB',
sample_count: rss.samples.length,
samples_null: rss.samples.filter((s) => s.rss_kib === null).length,
bad_lines: rss.bad_lines,
rounds,
gpu_memory_kib: null,
gpu_memory_status: 'unavailable',
godot,
verdict,
};
report.runner_cases = (Array.isArray(report.runner_cases) ? report.runner_cases : []).filter((c) => c?.id !== CASE_ID);
report.runner_cases.push({ id: CASE_ID, status: verdict.status, reason: verdict.reason });
if (!VALID_STATUS(report.status)) report.status = 'FAIL';
if (RANK[verdict.status] > RANK[report.status]) report.status = verdict.status;
report.exit_gate = report.exit_gate && typeof report.exit_gate === 'object' ? report.exit_gate : { checked: false, errors: [] };
if (!Array.isArray(report.exit_gate.errors)) report.exit_gate.errors = [];
if (verdict.status !== 'PASS') report.exit_gate.errors.push(`${CASE_ID}: ${verdict.status} ${verdict.reason}`);
return { report, code: { PASS: 0, FAIL: 1, BLOCKED: 2 }[report.status] ?? 1 };
}
function VALID_STATUS(status) { return Object.hasOwn(RANK, status); }
function usage(stream = process.stderr) {
stream.write('usage: playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000]\n'
+ ' playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl [--warmup-rounds 1] [--min-rounds 10]\n');
}
function argsOf(argv) {
const out = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (['--sample-rss', '--seal', '--help'].includes(arg)) out[arg.slice(2).replace('-', '_')] = true;
else if (arg.startsWith('--') && i + 1 < argv.length) out[arg.slice(2).replaceAll('-', '_')] = argv[++i];
else throw new Error(`bad argument: ${arg}`);
}
return out;
}
function readRss(pid) {
return new Promise((resolve) => {
execFile('ps', ['-o', 'stat=,rss=', '-p', String(pid)], { timeout: 5000 }, (error, stdout) => {
if (error) { resolve({ alive: false }); return; }
const [stat = '', rss = ''] = String(stdout).trim().split(/\s+/);
// An exited child the runner has not reaped yet is a zombie: it is gone, not 0 KiB.
if (stat.startsWith('Z')) { resolve({ alive: false }); return; }
const value = Number.parseInt(rss, 10);
resolve({ alive: true, rss_kib: Number.isInteger(value) && value > 0 ? value : null });
});
});
}
async function sampleRss(options) {
const pid = Number(options.pid);
const interval = Number(options.interval_ms ?? 1000);
if (!Number.isInteger(pid) || pid <= 0 || !options.output || !Number.isInteger(interval) || interval < 100) { usage(); return 2; }
let stop = false;
process.on('SIGTERM', () => { stop = true; });
process.on('SIGINT', () => { stop = true; });
while (!stop) {
const tick = Date.now();
const row = await readRss(pid);
if (!row.alive) break;
fs.appendFileSync(options.output, `${JSON.stringify({ wall_ms: tick, rss_kib: row.rss_kib })}\n`);
await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval - (Date.now() - tick))));
}
return 0;
}
function seal(options) {
for (const key of ['report', 'rss', 'events']) if (!options[key]) { usage(); return 2; }
let report;
try { report = JSON.parse(fs.readFileSync(options.report, 'utf8')); }
catch { console.error(`PLAYABLE MEMORY: FAIL report unreadable ${path.basename(options.report)}`); return 1; }
const read = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '');
const { report: sealed, code } = sealMemory(report, {
eventsText: read(options.events), rssText: read(options.rss),
warmupRounds: Number(options.warmup_rounds ?? 1), minRounds: Number(options.min_rounds ?? 10),
});
fs.writeFileSync(options.report, `${JSON.stringify(sealed, null, 2)}\n`);
const v = sealed.memory.verdict;
const line = `PLAYABLE MEMORY: ${v.status} ${v.reason} (first5=${v.first5_median_kib} last5=${v.last5_median_kib} KiB)`;
if (v.status === 'PASS') console.log(line); else console.error(line);
return code;
}
async function main() {
let options;
try { options = argsOf(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; }
if (options.help) { usage(process.stdout); return 0; }
if (options.sample_rss) return sampleRss(options);
if (options.seal) return seal(options);
usage();
return 2;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main();
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
/* STB-01 §9.1 memory gate regression. Synthetic RSS/rest-window data proves the
* leak rule (5 consecutive growing low waters AND last-5 median above
* max(50MiB, 5%) of the first-5 median), that missing data is BLOCKED rather
* than PASS, and that the external sampler records KiB for one PID only. */
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { parseRss, restWindows, roundLowWaters, leakVerdict, median, sealMemory } from './playable_soak_metrics.mjs';
const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_soak_metrics.mjs');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-soak-metrics.'));
let failures = 0;
const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } };
const MIB = 1024;
const event = (round, state, wallMs, us) => JSON.stringify({ monotonic_us: us, run_id: 'r', case_id: 'STB-MEMORY-01', connection_epoch: 1,
stage: 'REST', kind: 'soak_rest', actor_vid: 0, target_vid: 0, payload: { round, state, wall_ms: wallMs } });
/** Build rounds of rest windows (30 s each, 1 Hz samples) whose low water follows `lows` (KiB). */
function fixture(lows, { restMs = 30000, gapMs = 60000, dropSamplesInRound = -1 } = {}) {
const events = [];
const rss = [];
let t = 1_700_000_000_000;
lows.forEach((low, round) => {
for (let s = 1; s < gapMs / 1000; s += 1) rss.push({ wall_ms: t + s * 1000, rss_kib: low + 80 * MIB });
t += gapMs;
events.push(event(round, 'start', t, round * 10 + 1));
for (let s = 0; s <= restMs / 1000; s += 1) {
if (round !== dropSamplesInRound) rss.push({ wall_ms: t + s * 1000, rss_kib: low + (s === 20 ? 0 : 3 * MIB) });
}
t += restMs;
events.push(event(round, 'end', t, round * 10 + 2));
});
return { eventsText: `${events.join('\n')}\n`, rssText: `${rss.map((r) => JSON.stringify(r)).join('\n')}\n` };
}
const flat = Array.from({ length: 12 }, (_, i) => 900 * MIB + (i % 2) * MIB);
const leaking = Array.from({ length: 12 }, (_, i) => 900 * MIB + (i < 6 ? 0 : (i - 5) * 20 * MIB));
const sawtooth = Array.from({ length: 12 }, (_, i) => 900 * MIB + i * 30 * MIB - (i % 3 === 0 ? 40 * MIB : 0));
const smallCreep = Array.from({ length: 12 }, (_, i) => 900 * MIB + i * 2 * MIB);
check(median([3, 1, 2]) === 2 && median([4, 1, 3, 2]) === 2.5, 'median of odd and even lists');
{
const rows = parseRss('{"wall_ms":1,"rss_kib":10}\nnot json\n{"wall_ms":2,"rss_kib":null}\n');
check(rows.samples.length === 2 && rows.bad_lines === 1 && rows.samples[1].rss_kib === null, 'rss parser keeps null samples and counts bad lines');
}
{
const { eventsText, rssText } = fixture(flat);
const windows = restWindows(eventsText);
check(windows.windows.length === 12 && windows.windows[0].round === 0 && windows.windows[0].rest_ms === 30000, 'rest windows come from soak_rest start/end events');
const lows = roundLowWaters(windows.windows, parseRss(rssText).samples);
check(lows[3].low_water_kib === flat[3] && lows[3].samples === 31, 'low water is the minimum RSS inside the rest window');
const verdict = leakVerdict(lows, { warmupRounds: 1 });
check(verdict.status === 'PASS' && verdict.rounds_analysed === 11, `flat memory passes (${verdict.status} ${verdict.reason})`);
check(/不能证明长期无泄漏/.test(verdict.note), 'a pass still says it cannot prove no long-term leak');
check(verdict.unit === 'KiB' && Number.isInteger(verdict.first5_median_kib) && Number.isInteger(verdict.last5_median_kib), 'first/last 5 medians are recorded in KiB');
}
{
const { eventsText, rssText } = fixture(leaking);
const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 });
check(verdict.status === 'FAIL' && verdict.max_growth_streak >= 5 && verdict.growth_kib > verdict.threshold_kib, `steady growth fails (${JSON.stringify(verdict)})`);
check(verdict.threshold_kib === 50 * MIB, 'threshold is max(50MiB, 5%) — 50MiB for a 900MiB baseline');
}
{
// Large growth without 5 consecutive rising rounds is reported but does not fail the gate.
const verdict = leakVerdict(sawtooth.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
check(verdict.status === 'PASS' && verdict.max_growth_streak < 5 && verdict.growth_kib > verdict.threshold_kib && verdict.warnings.length === 1,
`median growth without a 5-round streak is a warning (${JSON.stringify(verdict)})`);
}
{
const verdict = leakVerdict(smallCreep.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
check(verdict.status === 'PASS' && verdict.max_growth_streak >= 5 && verdict.growth_kib <= verdict.threshold_kib, 'a monotonic creep below the threshold passes with the note');
}
{
const big = Array.from({ length: 12 }, (_, i) => 4096 * MIB + i * 60 * MIB);
const verdict = leakVerdict(big.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
check(verdict.threshold_kib > 50 * MIB && verdict.threshold_kib === Math.ceil(0.05 * verdict.first5_median_kib), '5% wins for a large baseline');
}
{
const verdict = leakVerdict(flat.slice(0, 10).map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
check(verdict.status === 'BLOCKED' && /10/.test(verdict.reason), 'fewer than 10 rounds after warm-up is BLOCKED');
}
{
const { eventsText, rssText } = fixture(flat, { dropSamplesInRound: 4 });
const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 });
check(verdict.status === 'BLOCKED' && /round 4/.test(verdict.reason), `a rest window without RSS samples is BLOCKED (${verdict.reason})`);
}
{
const { eventsText, rssText } = fixture(flat, { restMs: 10000 });
const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 });
check(verdict.status === 'FAIL' && /30/.test(verdict.reason), 'a rest window shorter than 30 s violates the contract');
}
{
const unmatched = `${event(0, 'start', 1000, 1)}\n${event(1, 'start', 2000, 2)}\n`;
const windows = restWindows(unmatched);
check(windows.windows.length === 0 && windows.errors.length === 2, 'unmatched rest events are errors, not windows');
}
// Seal: the memory verdict downgrades the sealed report and never upgrades it.
{
const { eventsText, rssText } = fixture(leaking);
const base = { schema_version: 1, run_id: 'r', suite: 'soak', status: 'PASS', exit_gate: { checked: true, process_code: 0, timed_out: false, errors: [] } };
const sealed = sealMemory(structuredClone(base), { eventsText, rssText, warmupRounds: 1 });
check(sealed.report.status === 'FAIL' && sealed.code === 1, 'leak downgrades PASS to FAIL');
check(sealed.report.runner_cases?.[0]?.id === 'STB-MEMORY-01' && sealed.report.runner_cases[0].status === 'FAIL', 'runner case STB-MEMORY-01 is recorded');
check(sealed.report.memory.gpu_memory_kib === null && sealed.report.memory.gpu_memory_status === 'unavailable', 'GPU memory stays null/unavailable');
check(sealed.report.exit_gate.errors.some((e) => e.startsWith('STB-MEMORY-01')), 'memory verdict reaches exit_gate.errors');
const blocked = sealMemory(structuredClone(base), { eventsText: '', rssText, warmupRounds: 1 });
check(blocked.report.status === 'BLOCKED' && blocked.code === 2, 'no rest windows downgrades PASS to BLOCKED');
const failed = sealMemory({ ...structuredClone(base), status: 'FAIL' }, { ...fixture(flat), warmupRounds: 1 });
check(failed.report.status === 'FAIL' && failed.code === 1 && failed.report.runner_cases[0].status === 'PASS', 'a clean memory verdict never upgrades a FAIL');
}
// CLI seal writes in place; unknown input is a usage error.
{
const { eventsText, rssText } = fixture(flat);
const dir = path.join(root, 'seal');
fs.mkdirSync(dir);
fs.writeFileSync(path.join(dir, 'events.jsonl'), eventsText);
fs.writeFileSync(path.join(dir, 'rss.jsonl'), rssText);
fs.writeFileSync(path.join(dir, 'report.json'), JSON.stringify({ schema_version: 1, run_id: 'r', suite: 'soak', status: 'PASS', exit_gate: { checked: true, errors: [] } }));
const run = spawnSync(process.execPath, [script, '--seal', '--report', path.join(dir, 'report.json'), '--rss', path.join(dir, 'rss.jsonl'),
'--events', path.join(dir, 'events.jsonl')], { encoding: 'utf8' });
const out = JSON.parse(fs.readFileSync(path.join(dir, 'report.json'), 'utf8'));
check(run.status === 0 && out.status === 'PASS' && out.memory.rounds.length === 12, `CLI seal PASS (${run.status} ${run.stderr})`);
const bad = spawnSync(process.execPath, [script, '--seal', '--report', path.join(dir, 'report.json')], { encoding: 'utf8' });
check(bad.status === 2, 'missing seal arguments exit 2');
const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' });
check(help.status === 0 && /--sample-rss/.test(help.stdout + help.stderr), '--help documents the sampler');
}
// Sampler: 1 Hz ps samples of one PID, KiB integers, stops by itself when that PID exits.
{
const target = spawn('/bin/sleep', ['3'], { stdio: 'ignore' });
const output = path.join(root, 'sampler.jsonl');
const started = Date.now();
const sampler = spawnSync(process.execPath, [script, '--sample-rss', '--pid', String(target.pid), '--output', output, '--interval-ms', '500'],
{ encoding: 'utf8', timeout: 15000 });
const rows = parseRss(fs.readFileSync(output, 'utf8')).samples;
check(sampler.status === 0 && Date.now() - started < 12000, `sampler exits after the PID is gone (${sampler.status} ${sampler.stderr})`);
check(rows.length >= 3 && rows.every((r) => Number.isInteger(r.rss_kib) && r.rss_kib > 0 && Number.isInteger(r.wall_ms)), `sampler writes KiB rows (${rows.length})`);
const refused = spawnSync(process.execPath, [script, '--sample-rss', '--pid', '0', '--output', output], { encoding: 'utf8' });
check(refused.status === 2, 'sampler refuses a non-positive PID');
}
fs.rmSync(root, { recursive: true, force: true });
console.log(failures === 0 ? 'PASS: playable_soak_metrics_test' : `FAIL: playable_soak_metrics_test (${failures})`);
process.exitCode = failures === 0 ? 0 : 1;
+106 -27
View File
@@ -1,56 +1,135 @@
#!/usr/bin/env bash
# Public entry for the first playable client gate. It delegates process
# ownership and post-exit validation to run_client_gate.sh.
# Public entry for the first playable client gate (INF-02 / REL-01).
#
# Runs N independent client processes through run_client_gate.sh into
# ROOT/run-<n>, writes ROOT/release-manifest.json listing exactly those runs and
# the candidate package hashes, then aggregates ONLY the listed runs. Historical
# PASS reports elsewhere are never scanned. Credentials are asked once (hidden
# TTY prompt) or taken from MT_ACCOUNT/MT_PASSWORD; never from arguments/files.
# Do not add `set -x` to this script.
#
# exit: 0 PASS, 1 FAIL, 2 BLOCKED, 124 a run hit the wall-clock timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
app="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
repo="$PWD"
process_cleanup_grace_seconds=15
source script/playable_process_cleanup.sh
app="${MT_PLAYABLE_APP:-}"
config=""
output=""
mode="playable"
timeout="180"
timeout="900"
allow=0
repeat=1
suite="full"
assets=""
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
usage() {
cat <<'EOF'
用法: script/playable_test.sh --config CONFIG [选项]
--app APP 导出包路径
--output DIR 单次运行输出目录(repeat=1
--suite full 当前固定为 full/playable
--allow-gameplay 允许发送游戏状态变更请求
--repeat N 独立进程运行次数,默认 1
--timeout-seconds N 每次墙钟超时,默认 180
用法: script/playable_test.sh --app APP --config CONFIG [选项]
--app APP 导出包路径(候选包;所有 run 必须是同一哈希)
--output DIR 本批次根目录,必须不存在或为空(默认 build/playable/suite-<时间>
--suite playable|full 必测用例集合,默认 full
--repeat N 独立进程运行次数,默认 1(发布要求见 docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md
--timeout-seconds N 每次墙钟超时,默认 900
--assets DIR 资源预检根目录(透传 run_client_gate.sh
--serverlist FILE 测试模式 serverlist 覆盖(透传)
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求
退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app="$2"; shift 2 ;;
--config) config="$2"; shift 2 ;;
--output) output="$2"; shift 2 ;;
--suite) suite="$2"; shift 2 ;;
--app) app="${2:-}"; shift 2 ;;
--config) config="${2:-}"; shift 2 ;;
--output) output="${2:-}"; shift 2 ;;
--suite) suite="${2:-}"; shift 2 ;;
--repeat) repeat="${2:-}"; shift 2 ;;
--timeout-seconds) timeout="${2:-}"; shift 2 ;;
--assets) assets="${2:-}"; shift 2 ;;
--serverlist) serverlist="${2:-}"; shift 2 ;;
--allow-gameplay) allow=1; shift ;;
--repeat) repeat="$2"; shift 2 ;;
--timeout-seconds) timeout="$2"; shift 2 ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ -z "$config" ] || [ ! -f "$config" ]; then echo "需要 --config CONFIG" >&2; exit 2; fi
if [ -z "$app" ] || [ ! -d "$app" ]; then echo "PLAYABLE SUITE: BLOCKED 需要 --app 指向候选导出包" >&2; exit 2; fi
if [ -z "$config" ] || [ ! -f "$config" ]; then echo "PLAYABLE SUITE: BLOCKED 需要 --config CONFIG" >&2; exit 2; fi
if [ "$suite" != "full" ] && [ "$suite" != "playable" ]; then echo "--suite 只支持 full/playable" >&2; exit 2; fi
if ! [[ "$repeat" =~ ^[1-9][0-9]*$ ]]; then echo "--repeat 必须是正整数" >&2; exit 2; fi
if [ "$repeat" -gt 1 ] && [ -n "$output" ]; then echo "repeat>1 时不能指定单一 --output" >&2; exit 2; fi
root="$output"
if [ -z "$root" ]; then root="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$}"; fi
if [ -z "$root" ]; then root="$repo/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$"; fi
if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "PLAYABLE SUITE: BLOCKED 输出目录已有内容,拒绝混入历史运行:$root" >&2
exit 2
fi
mkdir -p "$root"
failed=0
root="$(cd "$root" && pwd)"
# Ask once for the whole batch; child runners inherit the environment only.
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
if [ -t 0 ]; then
if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi
if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi
fi
fi
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
echo "PLAYABLE SUITE: BLOCKED 缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端" >&2
exit 2
fi
export MT_ACCOUNT MT_PASSWORD
# The candidate identity is computed once, before any run, from the same package.
bash script/playable_build_info.sh "$app" >"$root/candidate.json"
runs_json="[]"
timed_out=0
for index in $(seq 1 "$repeat"); do
run_dir="$root"
if [ "$repeat" -gt 1 ]; then run_dir="$root/run-$index"; fi
args=(--app "$app" --config "$config" --output "$run_dir" --mode "$mode" --timeout-seconds "$timeout")
run_dir="$root/run-$index"
args=(--app "$app" --config "$config" --output "$run_dir" --suite "$suite" --mode "$mode" --timeout-seconds "$timeout")
if [ -n "$assets" ]; then args+=(--assets "$assets"); fi
if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi
if [ "$allow" -eq 1 ]; then args+=(--allow-gameplay); fi
if ! bash script/run_client_gate.sh "${args[@]}"; then failed=$((failed + 1)); fi
set +e
bash script/run_client_gate.sh "${args[@]}" </dev/null &
child_pid=$!
wait "$child_pid"
code=$?
child_pid=""
set -e
if [ "$code" -eq 124 ]; then timed_out=1; fi
# A run that never produced a sealed report is still listed so the release fails loudly.
runs_json="$(node -e '
const fs = require("node:fs");
const [runs, dir, name, suite] = process.argv.slice(1);
let runId = `${name}-unsealed`;
try { runId = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")).run_id || runId; } catch {}
const list = JSON.parse(runs);
list.push({ run_id: runId, suite, path: name });
process.stdout.write(JSON.stringify(list));
' "$runs_json" "$run_dir" "run-$index" "$suite")"
done
if [ "$failed" -ne 0 ]; then echo "PLAYABLE SUITE: FAIL runs=$failed/$repeat" >&2; exit 1; fi
echo "PLAYABLE SUITE: PASS runs=$repeat root=$root"
node -e '
const fs = require("node:fs");
const [root, suite, repeat, runs] = process.argv.slice(1);
const candidate = JSON.parse(fs.readFileSync(`${root}/candidate.json`, "utf8"));
fs.writeFileSync(`${root}/release-manifest.json`, JSON.stringify({
schema_version: 1, candidate, required_runs: { [suite]: Number(repeat) }, runs: JSON.parse(runs),
}, null, 2) + "\n");
' "$root" "$suite" "$repeat" "$runs_json"
set +e
node script/validate_playable_report.mjs --release-dir "$root"
release_code=$?
set -e
if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE SUITE: TIMEOUT root=$root" >&2; exit 124; fi
case "$release_code" in
0) echo "PLAYABLE SUITE: PASS runs=$repeat root=$root" ;;
2) echo "PLAYABLE SUITE: BLOCKED root=$root" >&2 ;;
*) echo "PLAYABLE SUITE: FAIL root=$root" >&2; release_code=1 ;;
esac
exit "$release_code"
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/* Copy stdin to stdout line by line, replacing the test credentials before any
* byte reaches disk. Secrets come only from MT_ACCOUNT / MT_PASSWORD in the
* environment and are never printed, logged or passed as arguments.
*
* node script/redact_stream.mjs <fifo >client.log
*
* Uses blocking reads on fd 0: async stdin on a macOS FIFO does not reliably
* report EOF, which would leave the runner waiting for the redactor. */
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
const MIN_LENGTH = 2;
export function secretLiterals(env = process.env) {
return ['MT_PASSWORD', 'MT_ACCOUNT']
.map((name) => env[name] || '')
.filter((value) => value.length >= MIN_LENGTH)
// Longest first so an account contained in a password is not half-replaced.
.sort((a, b) => b.length - a.length);
}
export function redact(line, literals = secretLiterals()) {
let out = line;
for (const literal of literals) out = out.split(literal).join('[redacted]');
return out;
}
function pump() {
const literals = secretLiterals();
const buffer = Buffer.alloc(64 * 1024);
let pending = '';
const write = (text) => {
const bytes = Buffer.from(text);
let offset = 0;
while (offset < bytes.length) offset += fs.writeSync(1, bytes, offset);
};
for (;;) {
let count;
try {
count = fs.readSync(0, buffer, 0, buffer.length, null);
} catch (error) {
if (error.code === 'EAGAIN') continue;
if (error.code === 'EOF') break;
throw error;
}
if (count === 0) break;
pending += buffer.toString('utf8', 0, count);
const lines = pending.split('\n');
pending = lines.pop();
if (lines.length > 0) write(lines.map((line) => `${redact(line, literals)}\n`).join(''));
}
// A trailing partial line is still redacted as a whole.
if (pending !== '') write(`${redact(pending, literals)}\n`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) {
if (process.argv.includes('--help')) {
console.log('usage: node script/redact_stream.mjs < INPUT > OUTPUT (reads MT_ACCOUNT/MT_PASSWORD from env)');
} else {
pump();
}
}
+3 -3
View File
@@ -11,15 +11,15 @@ run_test() {
if [ "$mode" = headless ]; then options+=(--headless); fi
"${GODOT:-godot}" "${options[@]}" --script "$test_script" --quit-after 1800 >"$log" 2>&1
local status=$?
if [ "$status" -ne 0 ] || rg -q 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! rg -q 'PASS|failures=0' "$log"; then
if [ "$status" -ne 0 ] || grep -qE 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! grep -qE 'PASS|failures=0' "$log"; then
echo "FAIL $mode $test_script (exit=$status): $log"
failed=$((failed + 1))
else
echo "PASS $mode $test_script"
fi
if rg -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi
if grep -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi
}
for test_script in animation_cache_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd; do
for test_script in animation_cache_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd playable_combat_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd mob_view_test.gd net_world_vis_test.gd forest_viewpoints_test.gd playable_metrics_test.gd; do
run_test headless "$test_script"
done
for test_script in effect_faces_test.gd effect_lie_test.gd effect_texture_animation_test.gd effect_color_operation_test.gd effect_surface_test.gd effect_emission_test.gd effect_rotation_test.gd model_render_test.gd gpu_lod_attachment_test.gd gpu_pose_bounds_test.gd forest_mob_render_test.gd; do
+253 -67
View File
@@ -1,125 +1,311 @@
#!/usr/bin/env bash
# Run one exported client process and seal its report after exit.
# This script owns exactly one child PID; it never pkills other clients.
# INF-02: run ONE exported client process and seal its report after it exits.
#
# - Owns exactly one child PID (plus its own log redactor); never pkills other
# clients, never touches routes/firewall/system caches.
# - Credentials only from MT_ACCOUNT/MT_PASSWORD or a hidden TTY prompt; never
# from arguments or files. Do not add `set -x` to this script.
# - The client writes client-report.json; this script writes the final
# report.json only after the real process exit and the full-log gate.
# - suite soak (STB-01): also samples RSS of the child PID once per second into
# rss.jsonl, seals STB-MEMORY-01 into report.json (can only downgrade), and when
# soak.faults is confirmed runs script/playable_fault_proxy.mjs on 127.0.0.1 for
# this client's connection only (for every suite, since server.* then points at the
# proxy). Sampler and proxy are this script's own children.
#
# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
app_path="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
repo="$PWD"
app_path="${MT_PLAYABLE_APP:-}"
mode="playable"
suite="playable"
config_path=""
output_dir="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$}"
timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-180}"
assets_root=""
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
output_dir=""
timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-900}"
allow_gameplay=0
required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}"
godot_bin="${MT_GODOT:-godot}"
kill_grace_seconds=10
source script/playable_process_cleanup.sh
usage() {
cat <<'EOF'
用法: script/run_client_gate.sh --app APP --config CONFIG [选项]
选项:
--output DIR 本次运行目录,必须不存在或为空
--mode MODE 默认 playable
--timeout-seconds N 墙钟超时,默认 180
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求
--output DIR 本次运行目录,必须不存在或为空(默认 build/playable/run-<时间>-<pid>
--suite playable|full|soak 必测用例集合,默认 playablesoak 需 --timeout-seconds >= soak.duration_seconds+900
--mode MODE 包内 MT_TEST_MODE,默认 playable
--assets DIR 资源预检根目录(默认包内 Contents/Resources/assets
--serverlist FILE 测试模式 serverlist 覆盖;预检与客户端使用同一文件
--timeout-seconds N 墙钟超时,默认 900;超时 TERM,10 秒后 KILL
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求
环境变量:
MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入;否则返回 2)
MT_PLAYABLE_REQUIRED_ARCH 包必须包含的架构,默认 arm64
退出码: 0 PASS / 1 FAIL / 2 BLOCKED(配置或前置条件)/ 124 墙钟超时
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app_path="$2"; shift 2 ;;
--config) config_path="$2"; shift 2 ;;
--output) output_dir="$2"; shift 2 ;;
--mode) mode="$2"; shift 2 ;;
--timeout-seconds) timeout_seconds="$2"; shift 2 ;;
--app) app_path="${2:-}"; shift 2 ;;
--config) config_path="${2:-}"; shift 2 ;;
--output) output_dir="${2:-}"; shift 2 ;;
--suite) suite="${2:-}"; shift 2 ;;
--mode) mode="${2:-}"; shift 2 ;;
--assets) assets_root="${2:-}"; shift 2 ;;
--serverlist) serverlist="${2:-}"; shift 2 ;;
--timeout-seconds) timeout_seconds="${2:-}"; shift 2 ;;
--allow-gameplay) allow_gameplay=1; shift ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ -z "$config_path" ] || [ ! -f "$config_path" ]; then
echo "配置不存在;不会启动客户端:$config_path" >&2
output_ready=0
blocked() {
echo "PLAYABLE GATE: BLOCKED $*" >&2
if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi
exit 2
}
[ -n "$app_path" ] || blocked "需要 --app"
[ -n "$config_path" ] && [ -f "$config_path" ] || blocked "配置不存在;不会启动客户端"
[ "$suite" = "playable" ] || [ "$suite" = "full" ] || [ "$suite" = "soak" ] || blocked "--suite 只支持 playable/full/soak"
[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数"
config_path="$(cd "$(dirname "$config_path")" && pwd)/$(basename "$config_path")"
engine="$app_path/Contents/MacOS/mtgodot-poc"
[ -x "$engine" ] || blocked "找不到可执行包:$app_path"
if [ -n "$serverlist" ]; then
[ -f "$serverlist" ] || blocked "serverlist 不存在"
serverlist="$(cd "$(dirname "$serverlist")" && pwd)/$(basename "$serverlist")"
fi
if [ ! -x "$app_path/Contents/MacOS/mtgodot-poc" ]; then
echo "找不到可执行包:$app_path" >&2
exit 2
if [ -z "$output_dir" ]; then
output_dir="$repo/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$"
fi
if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then
echo "timeout-seconds 必须是正整数" >&2
exit 2
fi
if [ -e "$output_dir" ] && [ "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "输出目录已有内容,拒绝复用历史报告:$output_dir" >&2
exit 2
if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
blocked "输出目录已有内容,拒绝复用历史报告:$output_dir"
fi
mkdir -p "$output_dir"
output_dir="$(cd "$output_dir" && pwd)"
output_ready=1
# 2. package identity: signature and architecture before anything runs.
if ! codesign --verify --strict "$app_path" >/dev/null 2>&1; then
blocked "签名校验失败:codesign --verify --strict $app_path"
fi
archs="$(lipo -archs "$engine" 2>/dev/null || true)"
case " $archs " in
*" $required_arch "*) ;;
*) blocked "包架构 [$archs] 不含 $required_arch" ;;
esac
run_id="$(basename "$output_dir")-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')"
log="$output_dir/client.log"
client_report="$output_dir/client-report.json"
final_report="$output_dir/report.json"
events="$output_dir/events.jsonl"
if ! MT_PLAYABLE_VALIDATE_CONFIG="$config_path" godot --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then
echo "配置校验失败;不会启动客户端:$output_dir/config.log" >&2
exit 2
if [ -z "$assets_root" ]; then
if [ -d "$app_path/Contents/Resources/assets" ]; then
assets_root="$app_path/Contents/Resources/assets"
else
assets_root="$repo/assets"
fi
fi
char_slot="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const d=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(String(d.character_slot));' "$config_path")"
sha256_of() {
if [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else echo ""; fi
# 3. config + address + resource preconditions; resolves the address the client will use.
if ! env MT_PLAYABLE_VALIDATE_CONFIG="$config_path" MT_PLAYABLE_SUITE="$suite" \
MT_PLAYABLE_ASSETS="$assets_root" MT_PLAYABLE_SERVERLIST="$serverlist" \
MT_PLAYABLE_GATE_OUTPUT="$output_dir" \
"$godot_bin" --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then
blocked "配置/资源预检失败;不会启动客户端:$output_dir/config.log"
fi
# The config gate validated any soak block (for every suite); read only the fields the runner needs.
read -r soak_duration soak_warmup soak_min_rounds fault_status up_auth_host up_auth_port up_game_host up_game_port <<<"$(node -e '
const c = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
const s = c.soak || {}; const f = s.faults || {}; const u = f.upstream || {};
process.stdout.write([s.duration_seconds || 0, s.warmup_rounds ?? 1, s.min_rounds || 10, f.status || "unconfirmed", u.auth_host || "-", u.auth_port || 0, u.game_host || "-", u.game_port || 0].join(" "));
' "$config_path")"
if [ "$suite" = "soak" ]; then
[ "$timeout_seconds" -ge $((soak_duration + 900)) ] || blocked "soak 需要 --timeout-seconds >= soak.duration_seconds + 900(当前 ${timeout_seconds}"
fi
# Credentials: env or hidden prompt only.
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
if [ -t 0 ]; then
if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi
if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi
fi
fi
[ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端"
export MT_ACCOUNT MT_PASSWORD
read_address() {
node -e '
const a = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" "));
' "$output_dir/server-address.json"
}
engine="$app_path/Contents/MacOS/mtgodot-poc"
pck="$app_path/Contents/Resources/mtgodot-poc.pck"
dylib="$(find "$app_path/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' -print -quit)"
read -r auth_host auth_port game_host game_port <<<"$(read_address)"
proxy_pid=""
stop_proxy() {
if [ -n "$proxy_pid" ]; then
kill -TERM "$proxy_pid" 2>/dev/null || true
wait "$proxy_pid" 2>/dev/null || true
proxy_pid=""
fi
}
if [ "$fault_status" = "confirmed" ]; then
# Loopback proxy in front of the test server for this client only. server.* points at it, so
# playable exit runs sharing the scenario go through it too; only the soak client requests faults.
for endpoint in "$up_auth_host:$up_auth_port" "$up_game_host:$up_game_port"; do
if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then
blocked "故障代理上游不可达:$endpoint"
fi
done
routes=(--route "$auth_port=$up_auth_host:$up_auth_port")
if [ "$game_port" != "$auth_port" ]; then
routes+=(--route "$game_port=$up_game_host:$up_game_port")
elif [ "$up_game_host:$up_game_port" != "$up_auth_host:$up_auth_port" ]; then
blocked "auth/game 共用代理端口,但上游地址不同"
fi
node script/playable_fault_proxy.mjs "${routes[@]}" --events "$events" --log "$output_dir/fault-proxy.jsonl" \
--run-id "$run_id" >"$output_dir/fault-proxy.log" 2>&1 </dev/null &
proxy_pid=$!
proxy_wait=0
until grep -q '"action":"listening"' "$output_dir/fault-proxy.jsonl" 2>/dev/null; do
if ! kill -0 "$proxy_pid" 2>/dev/null || [ "$proxy_wait" -ge 50 ]; then
blocked "故障代理未能在 127.0.0.1 监听:$output_dir/fault-proxy.log"
fi
sleep 0.1
proxy_wait=$((proxy_wait + 1))
done
echo "FAULT PROXY pid=$proxy_pid listen=127.0.0.1:$auth_port,$game_port" >>"$output_dir/gate.log"
fi
for endpoint in "$auth_host:$auth_port" "$game_host:$game_port"; do
if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then
blocked "服务器端口不可达:$endpoint"
fi
done
char_slot="$(node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).character_slot))' "$config_path")"
# 1. identity of this run.
bash script/playable_build_info.sh "$app_path" >"$output_dir/build.json"
build_field() { node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"))[process.argv[2]]))' "$output_dir/build.json" "$1"; }
export MT_TEST_MODE="$mode"
export MT_PLAYABLE_SUITE="$suite"
export MT_PLAYABLE_CONFIG="$config_path"
export MT_PLAYABLE_RUN_ID="$run_id"
export MT_PLAYABLE_SERVERLIST="$serverlist"
export MT_TEST_REPORT="$client_report"
export MT_TEST_EVENTS="$events"
export MT_TEST_OUTPUT="$output_dir"
export MT_PROTOCOL="classic"
export MT_AUTOLOGIN=1
export MT_CHAR_SLOT="$char_slot"
export MT_PLAYABLE_RUN_ID="$(basename "$output_dir")"
export MT_BUILD_ENGINE_SHA256="$(sha256_of "$engine")"
export MT_BUILD_EXTENSION_SHA256="$(sha256_of "$dylib")"
export MT_BUILD_PCK_SHA256="$(sha256_of "$pck")"
file_description="$(file "$engine")"
case "$file_description" in
*arm64*) export MT_BUILD_ARCH="arm64" ;;
*x86_64*) export MT_BUILD_ARCH="x86_64" ;;
*) export MT_BUILD_ARCH="unknown" ;;
esac
if [ "$allow_gameplay" -eq 1 ]; then export MT_PLAYABLE_ALLOW_GAMEPLAY=1; else export MT_PLAYABLE_ALLOW_GAMEPLAY=0; fi
export MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)"
export MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)"
export MT_BUILD_PCK_SHA256="$(build_field pck_sha256)"
export MT_BUILD_ARCH="$(build_field arch)"
export MT_PLAYABLE_ALLOW_GAMEPLAY="$allow_gameplay"
start_epoch="$(date +%s)"
# 4/5. child stdout+stderr -> FIFO -> redactor -> client.log. The child PID is
# the engine itself (redirections are applied before exec), so `wait` returns
# its real status regardless of the log pipe.
fifo="$output_dir/.client.fifo"
mkfifo "$fifo"
started_ms="$(($(date +%s) * 1000))"
set +e
env -u MT_ASSETS "$engine" >"$log" 2>&1 &
node script/redact_stream.mjs <"$fifo" >"$log" &
redactor_pid=$!
env -u MT_ASSETS "$engine" >"$fifo" 2>&1 </dev/null &
child_pid=$!
sampler_pid=""
if [ "$suite" = "soak" ]; then
node script/playable_soak_metrics.mjs --sample-rss --pid "$child_pid" --output "$output_dir/rss.jsonl" \
</dev/null >>"$output_dir/gate.log" 2>&1 &
sampler_pid=$!
fi
SECONDS=0
timed_out=0
while kill -0 "$child_pid" 2>/dev/null; do
now="$(date +%s)"
if [ $((now - start_epoch)) -ge "$timeout_seconds" ]; then
if [ "$SECONDS" -ge "$timeout_seconds" ]; then
timed_out=1
kill -TERM "$child_pid" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$child_pid" 2>/dev/null || break
kill -TERM "$child_pid" 2>/dev/null
grace=0
while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do
sleep 1
grace=$((grace + 1))
done
kill -KILL "$child_pid" 2>/dev/null || true
killed=0
if kill -0 "$child_pid" 2>/dev/null; then kill -KILL "$child_pid" 2>/dev/null; killed=1; fi
echo "TIMEOUT term_grace_s=$grace killed=$killed" >>"$output_dir/gate.log"
break
fi
sleep 1
done
wait "$child_pid"
app_status=$?
set -e
if [ "$timed_out" -eq 1 ]; then app_status=124; fi
set +e
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
--log "$log" --process-code "$app_status" --require-pass
gate_status=$?
set -e
if [ "$app_status" -ne 0 ] || [ "$gate_status" -ne 0 ]; then
echo "PLAYABLE GATE: FAIL exit=$app_status report=$final_report log=$log" >&2
exit 1
process_code=$?
child_pid=""
# The redactor ends at EOF; bound the wait in case a grandchild kept the FIFO open.
redactor_wait=0
while kill -0 "$redactor_pid" 2>/dev/null && [ "$redactor_wait" -lt 10 ]; do
sleep 1
redactor_wait=$((redactor_wait + 1))
done
if kill -0 "$redactor_pid" 2>/dev/null; then
kill -TERM "$redactor_pid" 2>/dev/null
echo "PLAYABLE GATE: log redactor did not reach EOF (FIFO held open)" >>"$output_dir/gate.log"
fi
echo "PLAYABLE GATE: PASS report=$final_report log=$log"
wait "$redactor_pid"
redactor_code=$?
redactor_pid=""
rm -f "$fifo"
if [ -n "$sampler_pid" ]; then
# The sampler stops by itself once the child is gone; bound the wait anyway.
sampler_wait=0
while kill -0 "$sampler_pid" 2>/dev/null && [ "$sampler_wait" -lt 10 ]; do
sleep 1
sampler_wait=$((sampler_wait + 1))
done
if kill -0 "$sampler_pid" 2>/dev/null; then kill -TERM "$sampler_pid" 2>/dev/null; fi
wait "$sampler_pid"
sampler_pid=""
fi
stop_proxy
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
--log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \
--suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \
--events "$events" --started-ms "$started_ms" --redactor-code "$redactor_code" --require-pass
gate_status=$?
if [ "$suite" = "soak" ]; then
node script/playable_soak_metrics.mjs --seal --report "$final_report" --rss "$output_dir/rss.jsonl" --events "$events" \
--warmup-rounds "$soak_warmup" --min-rounds "$soak_min_rounds"
memory_status=$?
if [ "$gate_status" -ne 0 ] && [ "$gate_status" -ne 2 ]; then gate_status=1
elif [ "$memory_status" -ne 0 ] && [ "$memory_status" -ne 2 ]; then gate_status=1
elif [ "$gate_status" -eq 2 ] || [ "$memory_status" -eq 2 ]; then gate_status=2
fi
fi
set -e
if [ "$timed_out" -eq 1 ]; then
echo "PLAYABLE GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2
exit 124
fi
case "$gate_status" in
0) echo "PLAYABLE GATE: PASS run_id=$run_id report=$final_report" ;;
2) echo "PLAYABLE GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;;
*) echo "PLAYABLE GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;;
esac
exit "$gate_status"
+286 -63
View File
@@ -1,15 +1,37 @@
#!/usr/bin/env node
/* Validate the machine-readable playable report and, when requested, seal it
* with the parent process' exit/log gate. The client cannot mark a run PASS by
* itself: the final report is written only after the child has exited. */
/* INF-02 / REL-01 report gate.
*
* Run mode (called by run_client_gate.sh after the child exited):
* --report client-report.json --output report.json --log client.log
* --process-code N --timed-out 0|1 --run-id ID --suite S
* --required-cases required-cases.json --build build.json --events events.jsonl
* --started-ms MS [--redactor-code N] [--require-pass]
* Seals the final report. The client can never mark a run PASS by itself.
*
* Release mode:
* --release-dir DIR reads DIR/release-manifest.json, checks every explicitly
* listed sealed run (same candidate hashes, PASS, counts), writes
* DIR/release-report.json. History is never scanned.
*
* exit: 0 PASS, 1 FAIL, 2 BLOCKED (only BLOCKED/SKIP findings, clean process+log).
* Credential literals are read from MT_ACCOUNT/MT_PASSWORD and never printed. */
import fs from 'node:fs';
import path from 'node:path';
const VALID = new Set(['PASS', 'FAIL', 'BLOCKED', 'SKIP']);
const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/m;
const SUITES = new Set(['playable', 'full', 'soak', 'forest_render']);
// Same rule as script/package_render_test.sh, plus parse errors. No RID allow-list.
const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/;
const EVENT_KEYS = ['monotonic_us', 'run_id', 'case_id', 'connection_epoch', 'stage', 'kind', 'actor_vid', 'target_vid', 'payload'];
const HASH_KEYS = ['engine_sha256', 'extension_sha256', 'pck_sha256'];
const HEX64 = /^[0-9a-f]{64}$/;
const SECRET_MIN_SCAN = 4;
const MTIME_SLACK_MS = 1000;
function usage() {
console.error('usage: validate_playable_report.mjs --report PATH [--output PATH] [--log PATH] [--process-code N] [--run-id ID] [--require-pass]');
console.error('usage: validate_playable_report.mjs --report PATH --output PATH --log PATH --process-code N --run-id ID '
+ '--suite S --required-cases PATH --build PATH --events PATH --started-ms MS [--timed-out 0|1] [--require-pass]\n'
+ ' validate_playable_report.mjs --release-dir DIR');
}
function argsOf(argv) {
@@ -17,97 +39,298 @@ function argsOf(argv) {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--require-pass') out.require_pass = true;
else if (arg === '--help') out.help = true;
else if (arg.startsWith('--')) {
const key = arg.slice(2).replaceAll('-', '_');
if (i + 1 >= argv.length) throw new Error(`missing value for ${arg}`);
out[key] = argv[++i];
} else throw new Error(`unknown argument: ${arg}`);
}
if (!out.report) throw new Error('--report is required');
if (out.help) return out;
if (!out.release_dir) {
for (const key of ['report', 'output', 'log', 'process_code', 'run_id', 'suite', 'required_cases', 'build', 'events', 'started_ms']) {
if (out[key] === undefined) throw new Error(`--${key.replaceAll('_', '-')} is required`);
}
}
return out;
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (error) { return { __read_error: `${file}: ${error.message}` }; }
try {
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!isObject(value)) return { __read_error: `${path.basename(file)}: root must be an object` };
return value;
}
catch (error) { return { __read_error: `${path.basename(file)}: ${error.code || 'invalid JSON'}` }; }
}
function validate(report, options) {
const errors = [];
const readError = report?.__read_error;
if (readError) errors.push(readError);
function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
function secretLiterals() {
return ['MT_ACCOUNT', 'MT_PASSWORD'].map((name) => process.env[name] || '').filter((v) => v.length >= SECRET_MIN_SCAN);
}
function scanSecrets(label, text, errors) {
for (const literal of secretLiterals()) {
if (text.includes(literal)) errors.fail.push(`${label} contains a credential literal`);
}
}
class Findings {
constructor() { this.fail = []; this.blocked = []; }
get all() { return [...new Set([...this.fail, ...this.blocked])]; }
}
function checkBuild(build, expected, prefix, errors) {
for (const key of HASH_KEYS) {
if (typeof build?.[key] !== 'string' || !HEX64.test(build[key])) errors.fail.push(`${prefix}build.${key} is missing or not sha256`);
else if (expected && build[key] !== expected[key]) errors.fail.push(`${prefix}build.${key} differs from the candidate`);
}
if (typeof build?.arch !== 'string' || build.arch.trim() === '') errors.fail.push(`${prefix}build.arch is missing`);
else if (expected && build.arch !== expected.arch) errors.fail.push(`${prefix}build.arch differs from the candidate`);
}
function validateReport(report, options, errors) {
if (report.__read_error) {
errors.fail.push(`client report unreadable: ${report.__read_error}`);
return;
}
if (!report || typeof report !== 'object' || Array.isArray(report)) {
errors.push('report root must be an object');
return errors;
errors.fail.push('report root must be an object');
return;
}
if (report.schema_version !== 1) errors.push('schema_version must be 1');
if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.push('run_id is missing');
if (options.run_id && report.run_id !== options.run_id) errors.push('run_id does not match requested run');
if (report.suite !== 'playable') errors.push('suite must be playable');
if (!VALID.has(report.status)) errors.push(`invalid report status: ${report.status}`);
for (const key of ['engine_sha256', 'extension_sha256', 'pck_sha256', 'arch']) {
if (typeof report.build?.[key] !== 'string' || report.build[key].trim() === '') errors.push(`build.${key} is missing`);
}
if (!Array.isArray(report.cases) || report.cases.length === 0) errors.push('cases must be non-empty');
if (report.schema_version !== 1) errors.fail.push('schema_version must be 1');
if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.fail.push('run_id is missing');
else if (report.run_id !== options.run_id) errors.fail.push('run_id does not match this run (stale or foreign report)');
if (report.suite !== options.suite) errors.fail.push(`suite ${report.suite} does not match ${options.suite}`);
if (!VALID.has(report.status)) errors.fail.push(`invalid report status: ${report.status}`);
const expectedBuild = readJson(options.build);
if (expectedBuild.__read_error) errors.fail.push(`build.json unreadable: ${expectedBuild.__read_error}`);
checkBuild(report.build, expectedBuild.__read_error ? null : expectedBuild, '', errors);
const required = readJson(options.required_cases);
const requiredIds = Array.isArray(required?.cases) ? required.cases : null;
if (!requiredIds || requiredIds.length === 0) errors.fail.push('required-cases.json is missing or empty');
else if (required.suite !== options.suite) errors.fail.push('required-cases.json suite mismatch');
const cases = Array.isArray(report.cases) ? report.cases : [];
if (cases.length === 0) errors.fail.push('cases must be non-empty');
const seen = new Map();
let passed = 0;
for (const item of cases) {
if (!item || typeof item !== 'object' || typeof item.id !== 'string' || item.id.trim() === '') {
errors.push('case id is missing');
errors.fail.push('case id is missing');
continue;
}
if (!VALID.has(item.status)) errors.push(`${item.id}: invalid status`);
if (seen.has(item.id)) errors.fail.push(`${item.id}: duplicated case`);
seen.set(item.id, item);
if (!VALID.has(item.status)) errors.fail.push(`${item.id}: invalid status`);
if (item.status === 'PASS') passed += 1;
if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.push(`${item.id}: ${item.status} is not releasable`);
if (!Array.isArray(item.evidence)) errors.push(`${item.id}: evidence must be an array`);
if (item.status === 'FAIL') errors.fail.push(`${item.id}: FAIL ${item.reason || ''}`.trim());
if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.blocked.push(`${item.id}: ${item.status} is not releasable (${item.reason || 'no reason'})`);
if (!Array.isArray(item.evidence)) errors.fail.push(`${item.id}: evidence must be an array`);
for (const evidence of (Array.isArray(item.evidence) ? item.evidence : [])) {
if (typeof evidence !== 'string' || evidence.trim() === '') errors.push(`${item.id}: empty evidence path`);
else if (!fs.existsSync(path.resolve(path.dirname(options.report), evidence))) errors.push(`${item.id}: missing evidence ${evidence}`);
const resolved = typeof evidence === 'string' ? path.resolve(path.dirname(options.report), evidence) : '';
if (!resolved || !resolved.startsWith(path.resolve(path.dirname(options.report)) + path.sep)) errors.fail.push(`${item.id}: evidence path escapes the run directory`);
else if (!fs.existsSync(resolved)) errors.fail.push(`${item.id}: missing evidence ${evidence}`);
}
}
if (requiredIds) {
for (const id of requiredIds) if (!seen.has(id)) errors.fail.push(`${id}: required case missing from report`);
for (const id of seen.keys()) if (!requiredIds.includes(id)) errors.fail.push(`${id}: case is not in the required list`);
if (JSON.stringify(report.required_cases) !== JSON.stringify(requiredIds)) errors.fail.push('report required_cases differs from the runner list');
}
const coverage = report.coverage;
if (!coverage || coverage.required !== cases.length || coverage.passed !== passed) errors.push('coverage does not match cases');
if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.push('failures and blocked must be arrays');
if (!report.exit_gate || report.exit_gate.checked !== true) errors.push('exit_gate.checked must be true in final report');
if (options.process_code !== undefined && Number(report.exit_gate?.process_code) !== Number(options.process_code)) errors.push('exit_gate.process_code does not match child process');
if (Number(report.exit_gate?.process_code) !== 0) errors.push('child process did not exit with code 0');
if (options.log) {
if (!fs.existsSync(options.log)) errors.push(`log does not exist: ${options.log}`);
else {
const lines = fs.readFileSync(options.log, 'utf8').split(/\r?\n/);
lines.forEach((line, index) => { if (ERROR_RE.test(line)) errors.push(`log:${index + 1}: ${line}`); });
if (!coverage || coverage.required !== cases.length || coverage.passed !== passed
|| (requiredIds && coverage.required !== requiredIds.length)) errors.fail.push('coverage does not match cases');
if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.fail.push('failures and blocked must be arrays');
else if (report.failures.length > 0) errors.fail.push('client report contains failures');
if (report.status === 'PASS' && (passed !== cases.length || report.blocked?.length)) errors.fail.push('client PASS contradicts its cases');
// Staleness: the client report must have been written during this run.
try {
const mtime = fs.statSync(options.report).mtimeMs;
if (mtime + MTIME_SLACK_MS < Number(options.started_ms)) errors.fail.push('client report predates this run (stale)');
} catch { /* unreadable already reported */ }
// Event contract.
let lines = [];
if (fs.existsSync(options.events)) {
const text = fs.readFileSync(options.events, 'utf8');
scanSecrets('events', text, errors);
lines = text.split('\n').filter((line) => line.trim() !== '');
}
if (report.status === 'PASS' && lines.length === 0) errors.fail.push('PASS report has no events');
if (Number(report.event_count ?? 0) !== lines.length) errors.fail.push(`event_count ${report.event_count} does not match events lines ${lines.length}`);
let badEvents = 0;
let lastUs = -Infinity;
for (const line of lines) {
let event;
try { event = JSON.parse(line); } catch { badEvents += 1; continue; }
if (!isObject(event) || !EVENT_KEYS.every((key) => Object.hasOwn(event, key)) || event.run_id !== options.run_id
|| !Number.isSafeInteger(event.monotonic_us) || event.monotonic_us < 0
|| typeof event.payload !== 'object' || event.payload === null || Array.isArray(event.payload)
|| !Number.isInteger(event.connection_epoch) || event.monotonic_us < lastUs) badEvents += 1;
else lastUs = event.monotonic_us;
}
if (badEvents > 0) errors.fail.push(`${badEvents} events violate the contract (keys/run_id/order)`);
}
function validateProcess(options, errors) {
const code = Number(options.process_code);
if (options.timed_out === '1') errors.fail.push(`wall-clock timeout (raw exit ${code})`);
if (code !== 0) errors.fail.push(`child process exit code ${code}`);
if (options.redactor_code !== undefined && Number(options.redactor_code) !== 0) errors.fail.push('log redactor did not finish cleanly');
if (!fs.existsSync(options.log)) {
errors.fail.push('client log does not exist');
return;
}
const text = fs.readFileSync(options.log, 'utf8');
scanSecrets('client log', text, errors);
const hits = text.split(/\r?\n/).map((line, index) => [line, index]).filter(([line]) => ERROR_RE.test(line));
// Report the line numbers and the matched rule, not arbitrary log text.
for (const [line, index] of hits.slice(0, 50)) errors.fail.push(`log:${index + 1}: ${line.match(ERROR_RE)[0]}`);
if (hits.length > 50) errors.fail.push(`log: ${hits.length - 50} more error lines`);
}
function sealRun(options) {
const errors = new Findings();
const input = readJson(options.report);
validateReport(input, options, errors);
validateProcess(options, errors);
const report = input.__read_error ? {
schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL',
build: readJson(options.build), cases: [], failures: [input.__read_error], blocked: [],
coverage: { required: 0, passed: 0 },
} : input;
let status = 'PASS';
if (errors.fail.length > 0) status = 'FAIL';
else if (errors.blocked.length > 0) status = 'BLOCKED';
if (options.require_pass && status === 'PASS' && report.status !== 'PASS') { status = 'FAIL'; errors.fail.push(`client status ${report.status}`); }
report.status = status;
report.exit_gate = {
checked: true,
process_code: Number(options.process_code),
timed_out: options.timed_out === '1',
errors: errors.all,
};
const text = `${JSON.stringify(report, null, 2)}\n`;
const leak = new Findings();
scanSecrets('final report', text, leak);
const output = options.output;
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
if (leak.fail.length > 0) {
// Never persist a report that would carry the secret; keep only the verdict.
fs.writeFileSync(output, `${JSON.stringify({ schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL',
exit_gate: { checked: true, process_code: Number(options.process_code), timed_out: options.timed_out === '1', errors: leak.fail } }, null, 2)}\n`);
console.error('PLAYABLE REPORT: FAIL (credential literal in report)');
return 1;
}
fs.writeFileSync(output, text);
if (status !== 'PASS') {
console.error(`PLAYABLE REPORT: ${status} (${errors.all.length} findings)`);
errors.all.slice(0, 40).forEach((error) => console.error(`- ${error}`));
return status === 'BLOCKED' ? 2 : 1;
}
console.log(`PLAYABLE REPORT: PASS ${output}`);
return 0;
}
function sealRelease(dir) {
const errors = new Findings();
const root = path.resolve(dir);
const manifest = readJson(path.join(root, 'release-manifest.json'));
const summary = { schema_version: 1, status: 'FAIL', candidate: null, runs: [], counts: {}, errors: [] };
if (manifest.__read_error) errors.fail.push(`release-manifest.json unreadable: ${manifest.__read_error}`);
else {
if (manifest.schema_version !== 1) errors.fail.push('manifest schema_version must be 1');
checkBuild(manifest.candidate, null, 'manifest.', errors);
summary.candidate = manifest.candidate;
const runs = Array.isArray(manifest.runs) ? manifest.runs : [];
if (runs.length === 0) errors.fail.push('manifest lists no runs');
const ids = new Set();
const passedBySuite = {};
const blockedBySuite = {};
for (const run of runs) {
const label = `run ${run?.run_id ?? '?'}`;
if (typeof run?.run_id !== 'string' || typeof run?.path !== 'string' || !SUITES.has(run?.suite)) {
errors.fail.push(`${label}: run_id/path/suite missing`);
continue;
}
if (ids.has(run.run_id)) errors.fail.push(`${label}: listed twice`);
ids.add(run.run_id);
const reportPath = path.resolve(root, run.path, 'report.json');
if (!reportPath.startsWith(root + path.sep)) {
errors.fail.push(`${label}: path escapes the release directory`);
continue;
}
const report = readJson(reportPath);
const entry = { run_id: run.run_id, suite: run.suite, path: run.path, status: report.status ?? 'MISSING' };
summary.runs.push(entry);
if (report.__read_error) {
// run_client_gate.sh records precondition stops in gate.log before any client starts.
const gateLog = path.resolve(root, run.path, 'gate.log');
const preconditionBlocked = fs.existsSync(gateLog) && /^BLOCKED /m.test(fs.readFileSync(gateLog, 'utf8'));
if (preconditionBlocked) {
entry.status = 'BLOCKED';
blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1;
errors.blocked.push(`${label}: precondition BLOCKED before launch`);
} else errors.fail.push(`${label}: sealed report missing`);
continue;
}
if (report.run_id !== run.run_id) errors.fail.push(`${label}: report run_id mismatch`);
if (report.suite !== run.suite) errors.fail.push(`${label}: report suite mismatch`);
if (report.exit_gate?.checked !== true || report.exit_gate?.process_code !== 0 || report.exit_gate?.timed_out !== false) errors.fail.push(`${label}: report not sealed by a clean exit gate`);
// The client exit gate runs before the external RSS seal. An interrupted
// or crashed memory sealer must not leave a releasable intermediate PASS.
if (run.suite === 'soak' && report.status === 'PASS') {
const memoryCases = Array.isArray(report.runner_cases)
? report.runner_cases.filter((item) => item?.id === 'STB-MEMORY-01') : [];
if (memoryCases.length !== 1 || memoryCases[0].status !== 'PASS'
|| report.memory?.verdict?.status !== 'PASS') {
errors.fail.push(`${label}: STB-MEMORY-01 external memory seal missing or not PASS`);
}
}
checkBuild(report.build, manifest.candidate, `${label}: `, errors);
if (report.status === 'PASS') passedBySuite[run.suite] = (passedBySuite[run.suite] || 0) + 1;
else if (report.status === 'BLOCKED') {
blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1;
errors.blocked.push(`${label}: BLOCKED`);
}
else errors.fail.push(`${label}: ${report.status}`);
}
const requiredRuns = manifest.required_runs && typeof manifest.required_runs === 'object' ? manifest.required_runs : {};
if (Object.keys(requiredRuns).length === 0) errors.fail.push('manifest required_runs is empty');
for (const [suite, count] of Object.entries(requiredRuns)) {
const got = passedBySuite[suite] || 0;
summary.counts[suite] = { required: count, passed: got };
if (!Number.isInteger(count) || count < 1) errors.fail.push(`required_runs.${suite} must be a positive integer`);
else if (got < count) {
// Missing passes explained by BLOCKED runs stay BLOCKED; anything else is a FAIL.
const message = `${suite}: ${got}/${count} passing runs`;
if (got + (blockedBySuite[suite] || 0) >= count) errors.blocked.push(message);
else errors.fail.push(message);
}
}
}
if (options.require_pass && report.status !== 'PASS') errors.push(`report status is ${report.status}, expected PASS`);
if (report.status === 'FAIL') errors.push('client report already contains failures');
return [...new Set(errors)];
summary.status = errors.fail.length ? 'FAIL' : (errors.blocked.length ? 'BLOCKED' : 'PASS');
summary.errors = errors.all;
fs.writeFileSync(path.join(root, 'release-report.json'), `${JSON.stringify(summary, null, 2)}\n`);
if (summary.status !== 'PASS') {
console.error(`PLAYABLE RELEASE: ${summary.status}`);
summary.errors.forEach((error) => console.error(`- ${error}`));
return summary.status === 'BLOCKED' ? 2 : 1;
}
console.log(`PLAYABLE RELEASE: PASS ${path.join(root, 'release-report.json')}`);
return 0;
}
function main() {
let options;
try { options = argsOf(process.argv.slice(2)); }
catch (error) { usage(); console.error(error.message); return 2; }
const input = readJson(options.report);
const report = input.__read_error ? {
schema_version: 1, run_id: options.run_id || 'missing-report', suite: 'playable', status: 'FAIL',
build: { engine_sha256: '', extension_sha256: '', pck_sha256: '', arch: '' }, cases: [],
failures: [input.__read_error], blocked: [], coverage: { required: 0, passed: 0 },
} : input;
const exitCode = options.process_code === undefined ? null : Number(options.process_code);
report.exit_gate = { checked: true, process_code: exitCode, errors: [] };
const errors = validate(report, options);
report.exit_gate.errors = errors;
if (errors.length > 0) report.status = 'FAIL';
const output = options.output || options.report;
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
if (errors.length) {
console.error(`PLAYABLE REPORT: FAIL (${errors.length} errors)`);
errors.forEach((error) => console.error(`- ${error}`));
return 1;
}
console.log(`PLAYABLE REPORT: PASS ${output}`);
return 0;
if (options.help) { usage(); return 0; }
if (options.release_dir) return sealRelease(options.release_dir);
return sealRun(options);
}
process.exitCode = main();
+6
View File
@@ -0,0 +1,6 @@
{
"engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"arch": "arm64"
}
+1
View File
@@ -0,0 +1 @@
{"monotonic_us":1000,"run_id":"fixture-run","case_id":"FIXTURE-01","connection_epoch":1,"stage":"LOGIN","kind":"phase_changed","actor_vid":0,"target_vid":0,"payload":{"source":"server"}}
+55
View File
@@ -0,0 +1,55 @@
#!/bin/sh
# Stand-in for the packaged client used by script/playable_gate_test.sh.
# The fake app's Mach-O execs this file, so it runs as the runner's own child
# PID. It never contacts a server; MT_FAKE_SCENARIO picks the behaviour.
scenario="${MT_FAKE_SCENARIO:-pass}"
write_report() {
# $1 = PASS|BLOCKED, $2 = drop-last|evidence-missing|wrong-run|""
node -e '
const fs = require("node:fs");
const path = require("node:path");
const [status, variant] = process.argv.slice(1);
const out = process.env.MT_TEST_OUTPUT;
const required = JSON.parse(fs.readFileSync(path.join(out, "required-cases.json"), "utf8")).cases;
const runId = variant === "wrong-run" ? "previous-run-0000" : process.env.MT_PLAYABLE_RUN_ID;
const ids = variant === "drop-last" ? required.slice(0, -1) : required;
const cases = ids.map((id) => ({ id, status, duration_ms: 1, reason: status === "PASS" ? "fake" : "fixture_not_ready",
evidence: variant === "evidence-missing" && id === "NET-LOGIN-01" ? ["net-login-01.png"] : [] }));
const events = ids.map((id, i) => JSON.stringify({ monotonic_us: 1000 + i, run_id: runId, case_id: id, connection_epoch: 1,
stage: "LOGIN", kind: "phase_changed", actor_vid: 0, target_vid: 0, payload: { source: "server" } }));
fs.writeFileSync(process.env.MT_TEST_EVENTS, events.join("\n") + "\n");
const report = {
schema_version: 1, run_id: runId, suite: process.env.MT_PLAYABLE_SUITE || process.env.MT_TEST_MODE, status,
build: { engine_sha256: process.env.MT_BUILD_ENGINE_SHA256, extension_sha256: process.env.MT_BUILD_EXTENSION_SHA256,
pck_sha256: process.env.MT_BUILD_PCK_SHA256, arch: process.env.MT_BUILD_ARCH },
environment: { os: "macOS", renderer: "fake", resolution: [1280, 720] },
cases, required_cases: ids, events: "events.jsonl", event_count: events.length, failures: [],
blocked: status === "BLOCKED" ? ids.map((id) => `${id}: fixture_not_ready`) : [],
coverage: { required: cases.length, passed: status === "PASS" ? cases.length : 0 },
exit_gate: { checked: false, process_code: null, errors: [] },
};
fs.writeFileSync(process.env.MT_TEST_REPORT, JSON.stringify(report, null, 2));
' "$1" "${2:-}"
}
case "$scenario" in
cancel) exec node -e 'require("node:fs").writeFileSync(process.env.MT_TEST_OUTPUT + "/fake-child.pid", String(process.pid)); setInterval(() => {}, 1000)' ;;
pass) write_report PASS; echo "PLAYABLE CLIENT: PASS" ;;
soak-pass) write_report PASS; if [ "${MT_PLAYABLE_SUITE:-}" = "soak" ]; then sleep 3; fi; echo "PLAYABLE CLIENT: PASS" ;;
exit3) write_report PASS; echo "PLAYABLE CLIENT: PASS"; exit 3 ;;
signal) write_report PASS; kill -KILL $$ ;;
hang) trap '' TERM; echo "ignoring TERM"; while :; do sleep 1; done ;;
stale) write_report PASS wrong-run ;;
no-report) echo "exited before writing a report" ;;
rid) write_report PASS; echo "WARNING: 3 shaders of type CanvasItem were never freed." ;;
leak) write_report PASS; echo "WARNING: ObjectDB instances leaked at exit (run with --verbose for details)." ;;
missing-case) write_report PASS drop-last ;;
missing-evidence) write_report PASS evidence-missing ;;
blocked) write_report BLOCKED ;;
print-secret) write_report PASS; echo "login request for $MT_ACCOUNT with $MT_PASSWORD" ;;
fifo-holder) write_report PASS; sleep 13 & exit 0 ;;
no-creds) if [ -n "${MT_ACCOUNT:-}${MT_PASSWORD:-}" ]; then echo "SCRIPT ERROR: credentials reached an offline child"; fi; write_report PASS ;;
*) echo "unknown fake scenario" >&2; exit 64 ;;
esac
exit 0
@@ -0,0 +1,18 @@
{
"schema_version": 1,
"maps": {
"<map_key from map-assets.json>": {
"races": [],
"viewpoints": [
{
"id": "flat-01",
"kind": "flat",
"status": "unconfirmed",
"server_cm": [0, 0],
"camera_yaw_deg": 45
}
]
}
},
"notes": "Copy to test/playable/forest-viewpoints.<name>.local.json (ignored). kind is flat|slope|dense|warp; server_cm is the confirmed in-game global position; status becomes confirmed only with confirmed_by set to the confirming role (not a person's name). races lists server-confirmed mob vnums for the map; empty means the 12 candidate tree monsters are placed as candidates only. Never add credentials here."
}

Some files were not shown because too many files have changed in this diff Show More