diff --git a/.gitignore b/.gitignore
index d81f050c..ab7f3a2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
/build/
/build-*/
/cmake-build-*/
+/dist/
compile_commands.json
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4f4f9b00..3406645b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -16,7 +16,17 @@ option(MT_BUILD_TOOLS "Build libgr2 validation tools" OFF)
option(MT_BUILD_PORT "Build the 40250 port and its tests" ON)
option(MT_BUILD_NATIVE_RENDER "Build the SDL3 and Vulkan client" OFF)
-add_subdirectory(src/gr2)
+if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/../sdk/gr2)
+ message(STATUS "Using shared SDK: ../sdk/gr2")
+ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../sdk/gr2 ${CMAKE_CURRENT_BINARY_DIR}/sdk_gr2)
+else()
+ add_subdirectory(src/gr2)
+endif()
+
+if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/../sdk/speedtree)
+ message(STATUS "Using shared SDK: ../sdk/speedtree")
+ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../sdk/speedtree ${CMAKE_CURRENT_BINARY_DIR}/sdk_speedtree)
+endif()
add_subdirectory(third_party)
add_subdirectory(src/codepage)
if(MT_BUILD_PORT OR MT_BUILD_NATIVE_RENDER)
diff --git a/android/sync_client.py b/android/sync_client.py
index 26aa6d63..5cf7fcb4 100644
--- a/android/sync_client.py
+++ b/android/sync_client.py
@@ -50,6 +50,8 @@ def run_as(script, **kwargs):
def zh_inputs_digest(client_base):
h = hashlib.sha256()
inputs = [REPO / "scripts/build_zh_locale.py", client_base / "pack/Index"]
+ inputs += [REPO / "tools/server/biolog" / name for name in ("catalog.json", "shop.json", "make_assets.py")]
+ inputs.append(REPO / "tests/port/biolog_proto_patch.cpp")
inputs += sorted(p for p in (REPO / "data/zh_40250").rglob("*") if p.is_file())
for path in inputs:
h.update(str(path.relative_to(REPO)).encode())
diff --git a/audit/manifest.json b/audit/manifest.json
index eb5b6d3a..a6dadd6d 100644
--- a/audit/manifest.json
+++ b/audit/manifest.json
@@ -4268,6 +4268,9 @@
"last_test_result": "PARTIAL",
"commands": [
"2026-09-30: native UI HiDPI font refresh and sampling checks PASS; previous font runtime fails 9 new assertions. Full native CTest: 29 pass, codepage and live login skipped. Actual mac app HD archive/launch/screenshot checks recorded in audit/evidence/2026-09-30-mac-ui-clarity.md. Aggregate HUD parity remains PARTIAL."
+ ],
+ "notes": [
+ "2026-10-02: SCREEN waypoint atlas must retain original black RGB under alpha=0; HD minimap.dds colour bleed reproduced, native five-frame waypoint smoke renders clean rings after original-atlas fallback. build/minimap-fix-smoke.png. Full Windows visual parity remains unverified."
]
},
"remaining": [
diff --git a/data/zh_40250/README.md b/data/zh_40250/README.md
index 3b9820cd..d8261082 100644
--- a/data/zh_40250/README.md
+++ b/data/zh_40250/README.md
@@ -14,7 +14,9 @@
它们的审核译文(2026-09-28 确认)单独放在 `item_names_zh_overlay.txt`、
`mob_names_zh_overlay.txt`,界面回退键译文在 `locale_game_zh_overlay.txt`;
`scripts/build_zh_locale.py` 构建时以 overlay 覆盖快照同 VNUM / 同键的值。
-旧的龙驹中文表与线上表有 21 个相同 VNUM 不同名;不把旧表覆盖到服务端。
+中文译名以 `server_extracted/40250_deploy` 为优先参考;客户端技能和物品说明参考
+`server_extracted/client_locale/longju/locale/taiwan`。与资料冲突的译名通过 overlay 修正,
+线上原始快照保留不动。`server_extracted/database` 与部署表有少数地图名差异时采用部署表。
服务端名称若再更新,需重新只读导出并核对差异后更新本快照。
## 任务文本(translate.lua)
@@ -36,15 +38,16 @@
`scripts/build_zh_locale.py` 以 40250 的 `skilldesc.txt` 为底,逐行取龙驹同 VNUM 的名称、说明和条件列(2–8)
以及效果列(17/20/23)。客户端把效果列当作 `_snprintf` 格式串传入最小值、最大值,所以效果列只有在格式参数与英文一致时才替换。
-`skilldesc_zh_overlay.txt` 按 `VNUM\t列号\t译文` 覆盖龙驹缺的或者不对的格。例如 126–128 在龙驹里叫吴/蜀/魏国方言,
-这里改成盛唐语、秦皇语、汉武语;骑马技能的说明补上等级要求。
+`skilldesc_zh_overlay.txt` 按 `VNUM\t列号\t译文` 覆盖龙驹缺的或者不对的格。126–128 沿用资料里的吴/蜀/魏国方言;骑马技能说明沿用资料原文。
+效果列仍需适配当前客户端格式参数,例如反射机率使用 `%.1f%%`。
## 整行覆盖(item_list.txt、itemdesc.txt)
`item_list_zh_overlay.txt`、`itemdesc_zh_overlay.txt` 每行是目标文件的一整行(UTF-8,第一列 VNUM),
`build_zh_locale.py` 用它替换同 VNUM 的行(`ROW_FILES`),其余行字节不变;VNUM 不存在就报错。
-用于服务端改了用途的物品,例如 50307 小狩猎套装礼包(`tools/server/hunt-set`)换成礼盒图标和中文说明,
-70058 移动戒指(`tools/server/ring-warp`)写明不限等级、无限次、无冷却。
+50307 保留用户确认的自定义名称“小狩猎套装礼包”及原有说明;71118 恢复为勇气披风+及对应说明。
+70058 移动戒指(`tools/server/ring-warp`)的自定义说明保留;提取客户端资料没有该 VNUM 的说明。
+图标与使用限制不属于本次翻译修改。
## 物品限制(item_limits_server.tsv)
@@ -63,3 +66,24 @@
- 如果最后一行只剩一两个字,会把前面的行收窄一些重新折。
- 表里没有的格子保留英文,`zh-locale-report.json` 里的 `itemdesc.txt translation.english_rows` 会列出这些 VNUM。
- `itemdesc_zh_overlay.txt` 的整行覆盖在翻译之后执行,它的说明列也用同样的方式折行。
+
+## 2026-10-01 资料对齐
+
+修正 71118 的物品名和说明、16 个怪物/NPC/地图标记名及 12 个技能文本单元格。
+50307 按用户要求保留自定义礼包。
+UI 中为兼容当前客户端而调整的占位符、百分号及明显错字继续保留。
+
+### 武器名称来源
+
+武器译名以提取客户端 `server_extracted/client_locale/longju/locale/taiwan/item_proto`
+的 `szLocaleName` 为准。该文件为 MIPX v1、记录宽度 152 字节,按类型 WEAPON(1)和
+VNUM 匹配,不复制属性。它与 `server_extracted/database`、`40250_deploy` 的同 VNUM
+名称存在差异;武器优先采用提取客户端原型,其他名称继续参考部署表。
+
+本次修正 29 组武器的 +0~+9,共 290 条译名,例如武士刀→短刀、风雷剑→龙虎刀、
+鬼牙剑→夺魂刀、碧玉刀→封神刀。逐条来源和修改前名称见 `weapon_names_source_audit.tsv`。
+修改通过 `item_names_zh_overlay.txt` 生效,原始线上名称快照保留不动。
+
+## +6 镶嵌石配置修复(2026-10-02)
+
+当前 203 的 28700~28713 原为 ITEM_NONE,与 server_extracted 备份不同。`item_stones_server.tsv` 恢复备份中的类型、佩戴位置、属性和六个 value 字段;`locale_proto_patch` 只允许这 14 条记录的对应字段变化,其余非名称字段继续逐字节校验。服务端同份配置在 `tools/server/npc-shops/item-mall/stones.tsv`,商店安装时恢复。已有背包内同编号石头也使用新配置。
diff --git a/data/zh_40250/item_names_zh_overlay.txt b/data/zh_40250/item_names_zh_overlay.txt
index 97f0fa4b..7eb81026 100644
--- a/data/zh_40250/item_names_zh_overlay.txt
+++ b/data/zh_40250/item_names_zh_overlay.txt
@@ -1368,7 +1368,7 @@ VNUM LOCALE_NAME
71115 野猪坐骑券(蓝)
71116 狼坐骑券(黄)
71117 狼坐骑券(蓝)
-71118 虎坐骑券(黄)
+71118 勇气披风+
71119 虎坐骑券(蓝)
71120 狮坐骑券(黄)
71121 狮坐骑券(蓝)
@@ -1608,3 +1608,293 @@ VNUM LOCALE_NAME
164300 传说黑龙石
164400 传说黑龙石
50307 小狩猎套装礼包
+10 短刀+0
+11 短刀+1
+12 短刀+2
+13 短刀+3
+14 短刀+4
+15 短刀+5
+16 短刀+6
+17 短刀+7
+18 短刀+8
+19 短刀+9
+140 龙虎刀+0
+141 龙虎刀+1
+142 龙虎刀+2
+143 龙虎刀+3
+144 龙虎刀+4
+145 龙虎刀+5
+146 龙虎刀+6
+147 龙虎刀+7
+148 龙虎刀+8
+149 龙虎刀+9
+150 夺魂刀+0
+151 夺魂刀+1
+152 夺魂刀+2
+153 夺魂刀+3
+154 夺魂刀+4
+155 夺魂刀+5
+156 夺魂刀+6
+157 夺魂刀+7
+158 夺魂刀+8
+159 夺魂刀+9
+160 封神刀+0
+161 封神刀+1
+162 封神刀+2
+163 封神刀+3
+164 封神刀+4
+165 封神刀+5
+166 封神刀+6
+167 封神刀+7
+168 封神刀+8
+169 封神刀+9
+180 伏牺刀+0
+181 伏牺刀+1
+182 伏牺刀+2
+183 伏牺刀+3
+184 伏牺刀+4
+185 伏牺刀+5
+186 伏牺刀+6
+187 伏牺刀+7
+188 伏牺刀+8
+189 伏牺刀+9
+190 龙颚闪+0
+191 龙颚闪+1
+192 龙颚闪+2
+193 龙颚闪+3
+194 龙颚闪+4
+195 龙颚闪+5
+196 龙颚闪+6
+197 龙颚闪+7
+198 龙颚闪+8
+199 龙颚闪+9
+200 九龙神剑+0
+201 九龙神剑+1
+202 九龙神剑+2
+203 九龙神剑+3
+204 九龙神剑+4
+205 九龙神剑+5
+206 九龙神剑+6
+207 九龙神剑+7
+208 九龙神剑+8
+209 九龙神剑+9
+220 神兵‖天山苍穹刀+0
+221 神兵‖天山苍穹刀+1
+222 神兵‖天山苍穹刀+2
+223 神兵‖天山苍穹刀+3
+224 神兵‖天山苍穹刀+4
+225 神兵‖天山苍穹刀+5
+226 神兵‖天山苍穹刀+6
+227 神兵‖天山苍穹刀+7
+228 神兵‖天山苍穹刀+8
+229 神兵‖天山苍穹刀+9
+240 七窍破魂剑+0
+241 七窍破魂剑+1
+242 七窍破魂剑+2
+243 七窍破魂剑+3
+244 七窍破魂剑+4
+245 七窍破魂剑+5
+246 七窍破魂剑+6
+247 七窍破魂剑+7
+248 七窍破魂剑+8
+249 七窍破魂剑+9
+260 神兵‖晨星弑月刀+0
+261 神兵‖晨星弑月刀+1
+262 神兵‖晨星弑月刀+2
+263 神兵‖晨星弑月刀+3
+264 神兵‖晨星弑月刀+4
+265 神兵‖晨星弑月刀+5
+266 神兵‖晨星弑月刀+6
+267 神兵‖晨星弑月刀+7
+268 神兵‖晨星弑月刀+8
+269 神兵‖晨星弑月刀+9
+270 五虎伏魔剑+0
+271 五虎伏魔剑+1
+272 五虎伏魔剑+2
+273 五虎伏魔剑+3
+274 五虎伏魔剑+4
+275 五虎伏魔剑+5
+276 五虎伏魔剑+6
+277 五虎伏魔剑+7
+278 五虎伏魔剑+8
+279 五虎伏魔剑+9
+290 青焰刀+0
+291 青焰刀+1
+292 青焰刀+2
+293 青焰刀+3
+294 青焰刀+4
+295 青焰刀+5
+296 青焰刀+6
+297 青焰刀+7
+298 青焰刀+8
+299 青焰刀+9
+460 造化神器‖屠龙刀+0
+461 造化神器‖屠龙刀+1
+462 造化神器‖屠龙刀+2
+463 造化神器‖屠龙刀+3
+464 造化神器‖屠龙刀+4
+465 造化神器‖屠龙刀+5
+466 造化神器‖屠龙刀+6
+467 造化神器‖屠龙刀+7
+468 造化神器‖屠龙刀+8
+469 造化神器‖屠龙刀+9
+1100 冰牙+0
+1101 冰牙+1
+1102 冰牙+2
+1103 冰牙+3
+1104 冰牙+4
+1105 冰牙+5
+1106 冰牙+6
+1107 冰牙+7
+1108 冰牙+8
+1109 冰牙+9
+1120 乌皇剑+0
+1121 乌皇剑+1
+1122 乌皇剑+2
+1123 乌皇剑+3
+1124 乌皇剑+4
+1125 乌皇剑+5
+1126 乌皇剑+6
+1127 乌皇剑+7
+1128 乌皇剑+8
+1129 乌皇剑+9
+1160 神兵‖流光飞影+0
+1161 神兵‖流光飞影+1
+1162 神兵‖流光飞影+2
+1163 神兵‖流光飞影+3
+1164 神兵‖流光飞影+4
+1165 神兵‖流光飞影+5
+1166 神兵‖流光飞影+6
+1167 神兵‖流光飞影+7
+1168 神兵‖流光飞影+8
+1169 神兵‖流光飞影+9
+1170 吴钩+0
+1171 吴钩+1
+1172 吴钩+2
+1173 吴钩+3
+1174 吴钩+4
+1175 吴钩+5
+1176 吴钩+6
+1177 吴钩+7
+1178 吴钩+8
+1179 吴钩+9
+2160 魔神弓+0
+2161 魔神弓+1
+2162 魔神弓+2
+2163 魔神弓+3
+2164 魔神弓+4
+2165 魔神弓+5
+2166 魔神弓+6
+2167 魔神弓+7
+2168 魔神弓+8
+2169 魔神弓+9
+2170 铁牌弓+0
+2171 铁牌弓+1
+2172 铁牌弓+2
+2173 铁牌弓+3
+2174 铁牌弓+4
+2175 铁牌弓+5
+2176 铁牌弓+6
+2177 铁牌弓+7
+2178 铁牌弓+8
+2179 铁牌弓+9
+2180 神兵‖紫禁炫翼弓+0
+2181 神兵‖紫禁炫翼弓+1
+2182 神兵‖紫禁炫翼弓+2
+2183 神兵‖紫禁炫翼弓+3
+2184 神兵‖紫禁炫翼弓+4
+2185 神兵‖紫禁炫翼弓+5
+2186 神兵‖紫禁炫翼弓+6
+2187 神兵‖紫禁炫翼弓+7
+2188 神兵‖紫禁炫翼弓+8
+2189 神兵‖紫禁炫翼弓+9
+3150 炎龙魂+0
+3151 炎龙魂+1
+3152 炎龙魂+2
+3153 炎龙魂+3
+3154 炎龙魂+4
+3155 炎龙魂+5
+3156 炎龙魂+6
+3157 炎龙魂+7
+3158 炎龙魂+8
+3159 炎龙魂+9
+3160 狱焰+0
+3161 狱焰+1
+3162 狱焰+2
+3163 狱焰+3
+3164 狱焰+4
+3165 狱焰+5
+3166 狱焰+6
+3167 狱焰+7
+3168 狱焰+8
+3169 狱焰+9
+3200 神兵‖九天灭世刀+0
+3201 神兵‖九天灭世刀+1
+3202 神兵‖九天灭世刀+2
+3203 神兵‖九天灭世刀+3
+3204 神兵‖九天灭世刀+4
+3205 神兵‖九天灭世刀+5
+3206 神兵‖九天灭世刀+6
+3207 神兵‖九天灭世刀+7
+3208 神兵‖九天灭世刀+8
+3209 神兵‖九天灭世刀+9
+3210 玄冥剑+0
+3211 玄冥剑+1
+3212 玄冥剑+2
+3213 玄冥剑+3
+3214 玄冥剑+4
+3215 玄冥剑+5
+3216 玄冥剑+6
+3217 玄冥剑+7
+3218 玄冥剑+8
+3219 玄冥剑+9
+4040 日影黄泉+0
+4041 日影黄泉+1
+4042 日影黄泉+2
+4043 日影黄泉+3
+4044 日影黄泉+4
+4045 日影黄泉+5
+4046 日影黄泉+6
+4047 日影黄泉+7
+4048 日影黄泉+8
+4049 日影黄泉+9
+5090 天神铃+0
+5091 天神铃+1
+5092 天神铃+2
+5093 天神铃+3
+5094 天神铃+4
+5095 天神铃+5
+5096 天神铃+6
+5097 天神铃+7
+5098 天神铃+8
+5099 天神铃+9
+5120 青竹铃+0
+5121 青竹铃+1
+5122 青竹铃+2
+5123 青竹铃+3
+5124 青竹铃+4
+5125 青竹铃+5
+5126 青竹铃+6
+5127 青竹铃+7
+5128 青竹铃+8
+5129 青竹铃+9
+5150 神兵‖法神杖+0
+5151 神兵‖法神杖+1
+5152 神兵‖法神杖+2
+5153 神兵‖法神杖+3
+5154 神兵‖法神杖+4
+5155 神兵‖法神杖+5
+5156 神兵‖法神杖+6
+5157 神兵‖法神杖+7
+5158 神兵‖法神杖+8
+5159 神兵‖法神杖+9
+7160 七翎扇+0
+7161 七翎扇+1
+7162 七翎扇+2
+7163 七翎扇+3
+7164 七翎扇+4
+7165 七翎扇+5
+7166 七翎扇+6
+7167 七翎扇+7
+7168 七翎扇+8
+7169 七翎扇+9
diff --git a/data/zh_40250/item_stones_server.tsv b/data/zh_40250/item_stones_server.tsv
new file mode 100644
index 00000000..bb194237
--- /dev/null
+++ b/data/zh_40250/item_stones_server.tsv
@@ -0,0 +1,15 @@
+# vnum type subtype wear apply_type apply_value value0 value1 value2 value3 value4 value5
+28700 10 0 16 16 25 0 0 0 0 0 17
+28701 10 0 16 15 25 0 0 0 0 0 18
+28702 10 0 16 9 50 0 0 0 0 0 19
+28703 10 0 16 59 55 0 0 0 0 0 20
+28704 10 0 16 60 55 0 0 0 0 0 21
+28705 10 0 16 61 55 0 0 0 0 0 22
+28706 10 0 16 62 55 0 0 0 0 0 23
+28707 10 0 16 63 25 0 0 0 0 0 24
+28708 10 0 1 27 25 0 0 0 0 0 25
+28709 10 0 1 28 25 0 0 0 0 0 26
+28710 10 0 1 2 1000 0 0 0 0 0 27
+28711 10 0 1 1 2000 0 0 0 0 0 28
+28712 10 0 1 54 500 0 0 0 0 0 29
+28713 10 0 1 8 50 0 0 0 0 0 30
diff --git a/data/zh_40250/itemdesc_zh_overlay.txt b/data/zh_40250/itemdesc_zh_overlay.txt
index 1749d604..cda2f1d0 100644
--- a/data/zh_40250/itemdesc_zh_overlay.txt
+++ b/data/zh_40250/itemdesc_zh_overlay.txt
@@ -1,3 +1,4 @@
# vnum name description: whole itemdesc.txt rows (build_zh_locale.py ROW_FILES)
50307 小狩猎套装礼包 打开后按职业获得一套1级+9装备:武器、盔甲、头盔、盾牌、手镯、鞋子、项链、耳环,每件5条满值属性。需留15格背包空位,每个角色限开一次。
70058 移动戒指 此戒指可将你传送到本国一村、二村或各主要地图,任何等级都可使用,可无限次使用,没有冷却时间。不可交易。
+71118 勇气披风+ 古代战士的披风. 对敌人有莫名的吸引力 | 使用后每10秒召唤一次身边的怪物 | 升级时必备物品
diff --git a/data/zh_40250/mob_names_zh_overlay.txt b/data/zh_40250/mob_names_zh_overlay.txt
index ad75d458..ba9b81ed 100644
--- a/data/zh_40250/mob_names_zh_overlay.txt
+++ b/data/zh_40250/mob_names_zh_overlay.txt
@@ -230,3 +230,19 @@ VNUM LOCALE_NAME
33011 碧玉炼圣
33012 翡翠炼圣
33013 青玉炼圣
+10033 天意一层 10594 7525
+10034 天意二层 10594 7525
+10066 赤鬼木林 9915 11195
+10079 天意一层 1364 13436
+10080 天意二层 2413 12755
+10081 天意一层 99 13618
+10083 天意一层 1360 14972
+10084 天意二层 2414 14289
+10085 天意一层 99 15154
+10087 天意一层 1360 16508
+10088 天意二层 2414 15825
+10089 天意一层 99 16690
+10091 天意一层 1360 18044
+10092 天意二层 2414 17361
+20075 亡灵塔防具师
+20076 亡灵塔饰品师
diff --git a/data/zh_40250/skilldesc_zh_overlay.txt b/data/zh_40250/skilldesc_zh_overlay.txt
index ac6a0d9f..a7c59c37 100644
--- a/data/zh_40250/skilldesc_zh_overlay.txt
+++ b/data/zh_40250/skilldesc_zh_overlay.txt
@@ -5,15 +5,15 @@ VNUM COLUMN TEXT
19 8 不会被击倒
20 20 气绝机率 %.0f%%
64 20 对方攻击失败机率增加
-65 20 近身伤害反射 %.1f%%
-126 2 盛唐语
-126 5 可以听懂盛唐国的语言
-127 2 秦皇语
-127 5 可以听懂秦皇国的语言
-128 2 汉武语
-128 5 可以听懂汉武国的语言
-137 5 骑马奔跑并攻击周围的敌人(50级起)
-138 5 击倒前方拦截的敌人(52级起)
-139 5 攻击周围的敌人(55级起)
-140 5 攻击拦截的前方敌人(50级起)
-152 17 最大生命值 +%.0f%%
+65 20 物理攻击反射机率 %.1f%%
+126 2 吴国方言
+126 5 可以听与说吴国的方言
+127 2 蜀国方言
+127 5 可以听与说蜀国的方言
+128 2 魏国方言
+128 5 可以听与说魏国的方言
+137 5 骑马奔跑并攻击周围的敌人
+138 5 击倒前方拦截的敌人
+139 5 攻击周围的敌人
+140 5 攻击拦截的前方敌人
+152 17 提升最高Hp +%.0f%%
diff --git a/data/zh_40250/weapon_names_source_audit.tsv b/data/zh_40250/weapon_names_source_audit.tsv
new file mode 100644
index 00000000..9116dafe
--- /dev/null
+++ b/data/zh_40250/weapon_names_source_audit.tsv
@@ -0,0 +1,292 @@
+# Source: server_extracted/client_locale/longju/locale/taiwan/item_proto (MIPX v1, stride 152, type WEAPON=1)
+VNUM PREVIOUS_NAME SOURCE_NAME
+10 武士刀+0 短刀+0
+11 武士刀+1 短刀+1
+12 武士刀+2 短刀+2
+13 武士刀+3 短刀+3
+14 武士刀+4 短刀+4
+15 武士刀+5 短刀+5
+16 武士刀+6 短刀+6
+17 武士刀+7 短刀+7
+18 武士刀+8 短刀+8
+19 武士刀+9 短刀+9
+140 风雷剑+0 龙虎刀+0
+141 风雷剑+1 龙虎刀+1
+142 风雷剑+2 龙虎刀+2
+143 风雷剑+3 龙虎刀+3
+144 风雷剑+4 龙虎刀+4
+145 风雷剑+5 龙虎刀+5
+146 风雷剑+6 龙虎刀+6
+147 风雷剑+7 龙虎刀+7
+148 风雷剑+8 龙虎刀+8
+149 风雷剑+9 龙虎刀+9
+150 鬼牙剑+0 夺魂刀+0
+151 鬼牙剑+1 夺魂刀+1
+152 鬼牙剑+2 夺魂刀+2
+153 鬼牙剑+3 夺魂刀+3
+154 鬼牙剑+4 夺魂刀+4
+155 鬼牙剑+5 夺魂刀+5
+156 鬼牙剑+6 夺魂刀+6
+157 鬼牙剑+7 夺魂刀+7
+158 鬼牙剑+8 夺魂刀+8
+159 鬼牙剑+9 夺魂刀+9
+160 碧玉刀+0 封神刀+0
+161 碧玉刀+1 封神刀+1
+162 碧玉刀+2 封神刀+2
+163 碧玉刀+3 封神刀+3
+164 碧玉刀+4 封神刀+4
+165 碧玉刀+5 封神刀+5
+166 碧玉刀+6 封神刀+6
+167 碧玉刀+7 封神刀+7
+168 碧玉刀+8 封神刀+8
+169 碧玉刀+9 封神刀+9
+180 飞火流云刀+0 伏牺刀+0
+181 飞火流云刀+1 伏牺刀+1
+182 飞火流云刀+2 伏牺刀+2
+183 飞火流云刀+3 伏牺刀+3
+184 飞火流云刀+4 伏牺刀+4
+185 飞火流云刀+5 伏牺刀+5
+186 飞火流云刀+6 伏牺刀+6
+187 飞火流云刀+7 伏牺刀+7
+188 飞火流云刀+8 伏牺刀+8
+189 飞火流云刀+9 伏牺刀+9
+190 黑翼魔神刀+0 龙颚闪+0
+191 黑翼魔神刀+1 龙颚闪+1
+192 黑翼魔神刀+2 龙颚闪+2
+193 黑翼魔神刀+3 龙颚闪+3
+194 黑翼魔神刀+4 龙颚闪+4
+195 黑翼魔神刀+5 龙颚闪+5
+196 黑翼魔神刀+6 龙颚闪+6
+197 黑翼魔神刀+7 龙颚闪+7
+198 黑翼魔神刀+8 龙颚闪+8
+199 黑翼魔神刀+9 龙颚闪+9
+200 龙爪赤魂刀+0 九龙神剑+0
+201 龙爪赤魂刀+1 九龙神剑+1
+202 龙爪赤魂刀+2 九龙神剑+2
+203 龙爪赤魂刀+3 九龙神剑+3
+204 龙爪赤魂刀+4 九龙神剑+4
+205 龙爪赤魂刀+5 九龙神剑+5
+206 龙爪赤魂刀+6 九龙神剑+6
+207 龙爪赤魂刀+7 九龙神剑+7
+208 龙爪赤魂刀+8 九龙神剑+8
+209 龙爪赤魂刀+9 九龙神剑+9
+220 苍穹刀+0 神兵‖天山苍穹刀+0
+221 苍穹刀+1 神兵‖天山苍穹刀+1
+222 苍穹刀+2 神兵‖天山苍穹刀+2
+223 苍穹刀+3 神兵‖天山苍穹刀+3
+224 苍穹刀+4 神兵‖天山苍穹刀+4
+225 苍穹刀+5 神兵‖天山苍穹刀+5
+226 苍穹刀+6 神兵‖天山苍穹刀+6
+227 苍穹刀+7 神兵‖天山苍穹刀+7
+228 苍穹刀+8 神兵‖天山苍穹刀+8
+229 苍穹刀+9 神兵‖天山苍穹刀+9
+240 五刃刀+0 七窍破魂剑+0
+241 五刃刀+1 七窍破魂剑+1
+242 五刃刀+2 七窍破魂剑+2
+243 五刃刀+3 七窍破魂剑+3
+244 五刃刀+4 七窍破魂剑+4
+245 五刃刀+5 七窍破魂剑+5
+246 五刃刀+6 七窍破魂剑+6
+247 五刃刀+7 七窍破魂剑+7
+248 五刃刀+8 七窍破魂剑+8
+249 五刃刀+9 七窍破魂剑+9
+260 弑月刀+0 神兵‖晨星弑月刀+0
+261 弑月刀+1 神兵‖晨星弑月刀+1
+262 弑月刀+2 神兵‖晨星弑月刀+2
+263 弑月刀+3 神兵‖晨星弑月刀+3
+264 弑月刀+4 神兵‖晨星弑月刀+4
+265 弑月刀+5 神兵‖晨星弑月刀+5
+266 弑月刀+6 神兵‖晨星弑月刀+6
+267 弑月刀+7 神兵‖晨星弑月刀+7
+268 弑月刀+8 神兵‖晨星弑月刀+8
+269 弑月刀+9 神兵‖晨星弑月刀+9
+270 紫云伏魔刀+0 五虎伏魔剑+0
+271 紫云伏魔刀+1 五虎伏魔剑+1
+272 紫云伏魔刀+2 五虎伏魔剑+2
+273 紫云伏魔刀+3 五虎伏魔剑+3
+274 紫云伏魔刀+4 五虎伏魔剑+4
+275 紫云伏魔刀+5 五虎伏魔剑+5
+276 紫云伏魔刀+6 五虎伏魔剑+6
+277 紫云伏魔刀+7 五虎伏魔剑+7
+278 紫云伏魔刀+8 五虎伏魔剑+8
+279 紫云伏魔刀+9 五虎伏魔剑+9
+290 赤焰刀+0 青焰刀+0
+291 赤焰刀+1 青焰刀+1
+292 赤焰刀+2 青焰刀+2
+293 赤焰刀+3 青焰刀+3
+294 赤焰刀+4 青焰刀+4
+295 赤焰刀+5 青焰刀+5
+296 赤焰刀+6 青焰刀+6
+297 赤焰刀+7 青焰刀+7
+298 赤焰刀+8 青焰刀+8
+299 赤焰刀+9 青焰刀+9
+1100 无极双刀+0 冰牙+0
+1101 无极双刀+1 冰牙+1
+1102 无极双刀+2 冰牙+2
+1103 无极双刀+3 冰牙+3
+1104 无极双刀+4 冰牙+4
+1105 无极双刀+5 冰牙+5
+1106 无极双刀+6 冰牙+6
+1107 无极双刀+7 冰牙+7
+1108 无极双刀+8 冰牙+8
+1109 无极双刀+9 冰牙+9
+1120 夜魔异刃双刀+0 乌皇剑+0
+1121 夜魔异刃双刀+1 乌皇剑+1
+1122 夜魔异刃双刀+2 乌皇剑+2
+1123 夜魔异刃双刀+3 乌皇剑+3
+1124 夜魔异刃双刀+4 乌皇剑+4
+1125 夜魔异刃双刀+5 乌皇剑+5
+1126 夜魔异刃双刀+6 乌皇剑+6
+1127 夜魔异刃双刀+7 乌皇剑+7
+1128 夜魔异刃双刀+8 乌皇剑+8
+1129 夜魔异刃双刀+9 乌皇剑+9
+1160 流光飞影+0 神兵‖流光飞影+0
+1161 流光飞影+1 神兵‖流光飞影+1
+1162 流光飞影+2 神兵‖流光飞影+2
+1163 流光飞影+3 神兵‖流光飞影+3
+1164 流光飞影+4 神兵‖流光飞影+4
+1165 流光飞影+5 神兵‖流光飞影+5
+1166 流光飞影+6 神兵‖流光飞影+6
+1167 流光飞影+7 神兵‖流光飞影+7
+1168 流光飞影+8 神兵‖流光飞影+8
+1169 流光飞影+9 神兵‖流光飞影+9
+1170 闪燕双刀+0 吴钩+0
+1171 闪燕双刀+1 吴钩+1
+1172 闪燕双刀+2 吴钩+2
+1173 闪燕双刀+3 吴钩+3
+1174 闪燕双刀+4 吴钩+4
+1175 闪燕双刀+5 吴钩+5
+1176 闪燕双刀+6 吴钩+6
+1177 闪燕双刀+7 吴钩+7
+1178 闪燕双刀+8 吴钩+8
+1179 闪燕双刀+9 吴钩+9
+2160 魔神武尊弓+0 魔神弓+0
+2161 魔神武尊弓+1 魔神弓+1
+2162 魔神武尊弓+2 魔神弓+2
+2163 魔神武尊弓+3 魔神弓+3
+2164 魔神武尊弓+4 魔神弓+4
+2165 魔神武尊弓+5 魔神弓+5
+2166 魔神武尊弓+6 魔神弓+6
+2167 魔神武尊弓+7 魔神弓+7
+2168 魔神武尊弓+8 魔神弓+8
+2169 魔神武尊弓+9 魔神弓+9
+2170 金鹏万里弓+0 铁牌弓+0
+2171 金鹏万里弓+1 铁牌弓+1
+2172 金鹏万里弓+2 铁牌弓+2
+2173 金鹏万里弓+3 铁牌弓+3
+2174 金鹏万里弓+4 铁牌弓+4
+2175 金鹏万里弓+5 铁牌弓+5
+2176 金鹏万里弓+6 铁牌弓+6
+2177 金鹏万里弓+7 铁牌弓+7
+2178 金鹏万里弓+8 铁牌弓+8
+2179 金鹏万里弓+9 铁牌弓+9
+2180 炫翼弓+0 神兵‖紫禁炫翼弓+0
+2181 炫翼弓+1 神兵‖紫禁炫翼弓+1
+2182 炫翼弓+2 神兵‖紫禁炫翼弓+2
+2183 炫翼弓+3 神兵‖紫禁炫翼弓+3
+2184 炫翼弓+4 神兵‖紫禁炫翼弓+4
+2185 炫翼弓+5 神兵‖紫禁炫翼弓+5
+2186 炫翼弓+6 神兵‖紫禁炫翼弓+6
+2187 炫翼弓+7 神兵‖紫禁炫翼弓+7
+2188 炫翼弓+8 神兵‖紫禁炫翼弓+8
+2189 炫翼弓+9 神兵‖紫禁炫翼弓+9
+3150 飞龙裂空斩+0 炎龙魂+0
+3151 飞龙裂空斩+1 炎龙魂+1
+3152 飞龙裂空斩+2 炎龙魂+2
+3153 飞龙裂空斩+3 炎龙魂+3
+3154 飞龙裂空斩+4 炎龙魂+4
+3155 飞龙裂空斩+5 炎龙魂+5
+3156 飞龙裂空斩+6 炎龙魂+6
+3157 飞龙裂空斩+7 炎龙魂+7
+3158 飞龙裂空斩+8 炎龙魂+8
+3159 飞龙裂空斩+9 炎龙魂+9
+3160 炎狱霸王斩+0 狱焰+0
+3161 炎狱霸王斩+1 狱焰+1
+3162 炎狱霸王斩+2 狱焰+2
+3163 炎狱霸王斩+3 狱焰+3
+3164 炎狱霸王斩+4 狱焰+4
+3165 炎狱霸王斩+5 狱焰+5
+3166 炎狱霸王斩+6 狱焰+6
+3167 炎狱霸王斩+7 狱焰+7
+3168 炎狱霸王斩+8 狱焰+8
+3169 炎狱霸王斩+9 狱焰+9
+3200 灭世刀+0 神兵‖九天灭世刀+0
+3201 灭世刀+1 神兵‖九天灭世刀+1
+3202 灭世刀+2 神兵‖九天灭世刀+2
+3203 灭世刀+3 神兵‖九天灭世刀+3
+3204 灭世刀+4 神兵‖九天灭世刀+4
+3205 灭世刀+5 神兵‖九天灭世刀+5
+3206 灭世刀+6 神兵‖九天灭世刀+6
+3207 灭世刀+7 神兵‖九天灭世刀+7
+3208 灭世刀+8 神兵‖九天灭世刀+8
+3209 灭世刀+9 神兵‖九天灭世刀+9
+3210 银翎斩+0 玄冥剑+0
+3211 银翎斩+1 玄冥剑+1
+3212 银翎斩+2 玄冥剑+2
+3213 银翎斩+3 玄冥剑+3
+3214 银翎斩+4 玄冥剑+4
+3215 银翎斩+5 玄冥剑+5
+3216 银翎斩+6 玄冥剑+6
+3217 银翎斩+7 玄冥剑+7
+3218 银翎斩+8 玄冥剑+8
+3219 银翎斩+9 玄冥剑+9
+4040 逆天夺魄双刀+0 日影黄泉+0
+4041 逆天夺魄双刀+1 日影黄泉+1
+4042 逆天夺魄双刀+2 日影黄泉+2
+4043 逆天夺魄双刀+3 日影黄泉+3
+4044 逆天夺魄双刀+4 日影黄泉+4
+4045 逆天夺魄双刀+5 日影黄泉+5
+4046 逆天夺魄双刀+6 日影黄泉+6
+4047 逆天夺魄双刀+7 日影黄泉+7
+4048 逆天夺魄双刀+8 日影黄泉+8
+4049 逆天夺魄双刀+9 日影黄泉+9
+5090 凤鸣乾坤铃+0 天神铃+0
+5091 凤鸣乾坤铃+1 天神铃+1
+5092 凤鸣乾坤铃+2 天神铃+2
+5093 凤鸣乾坤铃+3 天神铃+3
+5094 凤鸣乾坤铃+4 天神铃+4
+5095 凤鸣乾坤铃+5 天神铃+5
+5096 凤鸣乾坤铃+6 天神铃+6
+5097 凤鸣乾坤铃+7 天神铃+7
+5098 凤鸣乾坤铃+8 天神铃+8
+5099 凤鸣乾坤铃+9 天神铃+9
+5120 蛇魔夺魂铃+0 青竹铃+0
+5121 蛇魔夺魂铃+1 青竹铃+1
+5122 蛇魔夺魂铃+2 青竹铃+2
+5123 蛇魔夺魂铃+3 青竹铃+3
+5124 蛇魔夺魂铃+4 青竹铃+4
+5125 蛇魔夺魂铃+5 青竹铃+5
+5126 蛇魔夺魂铃+6 青竹铃+6
+5127 蛇魔夺魂铃+7 青竹铃+7
+5128 蛇魔夺魂铃+8 青竹铃+8
+5129 蛇魔夺魂铃+9 青竹铃+9
+5150 法神杖+0 神兵‖法神杖+0
+5151 法神杖+1 神兵‖法神杖+1
+5152 法神杖+2 神兵‖法神杖+2
+5153 法神杖+3 神兵‖法神杖+3
+5154 法神杖+4 神兵‖法神杖+4
+5155 法神杖+5 神兵‖法神杖+5
+5156 法神杖+6 神兵‖法神杖+6
+5157 法神杖+7 神兵‖法神杖+7
+5158 法神杖+8 神兵‖法神杖+8
+5159 法神杖+9 神兵‖法神杖+9
+7160 骨魔卷云扇+0 七翎扇+0
+7161 骨魔卷云扇+1 七翎扇+1
+7162 骨魔卷云扇+2 七翎扇+2
+7163 骨魔卷云扇+3 七翎扇+3
+7164 骨魔卷云扇+4 七翎扇+4
+7165 骨魔卷云扇+5 七翎扇+5
+7166 骨魔卷云扇+6 七翎扇+6
+7167 骨魔卷云扇+7 七翎扇+7
+7168 骨魔卷云扇+8 七翎扇+8
+7169 骨魔卷云扇+9 七翎扇+9
+460 屠龙刀+0 造化神器‖屠龙刀+0
+461 屠龙刀+1 造化神器‖屠龙刀+1
+462 屠龙刀+2 造化神器‖屠龙刀+2
+463 屠龙刀+3 造化神器‖屠龙刀+3
+464 屠龙刀+4 造化神器‖屠龙刀+4
+465 屠龙刀+5 造化神器‖屠龙刀+5
+466 屠龙刀+6 造化神器‖屠龙刀+6
+467 屠龙刀+7 造化神器‖屠龙刀+7
+468 屠龙刀+8 造化神器‖屠龙刀+8
+469 屠龙刀+9 造化神器‖屠龙刀+9
diff --git a/docs/AUTO-HUNT-ENHANCEMENTS.md b/docs/AUTO-HUNT-ENHANCEMENTS.md
index 39bde921..59875fa8 100644
--- a/docs/AUTO-HUNT-ENHANCEMENTS.md
+++ b/docs/AUTO-HUNT-ENHANCEMENTS.md
@@ -41,3 +41,12 @@ F8 或移动端自动狩猎设置打开面板,选择已学会的技能。选
集成测试使用假服并模拟骑乘/存活确认;真实服务器拒绝条件、复杂地图上的长期寻路和真机长时间挂机尚需实测。
中文面板已用原生 Vulkan 渲染器检查手机布局,截图位于 `build/qa/autohunt-enhance-ui/settings.png`;截图使用模拟的可选技能列表,实际已学技能过滤由上述集成测试覆盖。
+
+
+## 2026-10-02:拥堵与拾取抢占
+
+自动狩猎检测近身且无静态阻挡的合法怪物。拾取期间出现这类怪物立即清除物品预约和路径,转为战斗;近身有两只以上怪物,或追击 1.5 秒没有进展时,也优先清理近身目标。保留当前近身目标,避免频繁换向。拥堵期间及解除后的 3 秒不补拉、不被外围石头抢占;首领过滤仍生效。暂缓的物品跳过 2 秒,后续重新扫描。
+
+已处于原有可攻击距离且直线无静态阻挡时,直接进入攻击流程,不再要求走到固定 100 cm 站位。攻击进展由本地碰撞命中回调更新,不再由攻击动画刷新;这是客户端命中证据,并非服务器伤害确认。施法和眩晕期间保留等待。移动进展按到当前路点的最佳距离减少或通过路点更新,横向绕圈不刷新。物品尝试另有 15 秒总期限,超时清除路径和移动。
+
+新增假服回归覆盖拾取转战斗和威胁消失后的重新拾取。真实密集怪群、骑乘和墙边拥堵需真机验证。本次不加入动态 A* 怪物障碍,也不修改服务端。
diff --git a/docs/AVA2-40250-COMPARISON.md b/docs/AVA2-40250-COMPARISON.md
new file mode 100644
index 00000000..f4f2307f
--- /dev/null
+++ b/docs/AVA2-40250-COMPARISON.md
@@ -0,0 +1,189 @@
+# AVA2 与 40250 客户端差异审查
+
+日期:2026-10-03。结论:同源引擎上的大幅功能分支,协议与数据不兼容;不是当前跨平台客户端的可直接替换升级版。
+
+## 范围与证据边界
+
+- AVA2 客户端:`AVA2/TEST/3. Source BIN/Client`;脚本和解包资源:`AVA2/TEST/1. Client DEV/pack_maker`。
+- 40250 客户端:`40250/Server Client TMP4/ClientVS22/source`;原始 Python:`40250/Server Client TMP4/Client/Eternexus/root`。
+- AVA2 服务端:只读取 `FILES+SRC.tgz`,将 Server 源码提取到 `metin2-client/build/ava2-comparison/server`,没有执行包内脚本、启动服务器或导入数据库。
+- 40250 服务端:`40250/Server Client TMP4/Server/metin2/src/server`。
+- 当前项目与原始 40250 分开说明:当前项目已经是 SDL3/Vulkan arm64,并有 5 页背包、中文、移动操作、自动狩猎等扩展,不能把这些算成“原始 40250 功能”。
+- 用户提供的[发布帖](https://metin2hub.com/threads/ava2-org-serverfiles-and-source-full-version.1269/)主页面直接读取返回 403;搜索索引能找到同一帖标题及 2023-01-15 的发帖信息,但未据此认定功能完整性。以下功能结论来自本地源码。
+- 本次完成目录清点、文件内容比较、主要编译开关及构建列表核对、重点协议/数据/碰撞/拾取路径审阅。没有逐函数证明两个客户端等价,也没有运行 AVA2 Windows 客户端。因此“源码接入”不等于“真机稳定”。
+
+## 1. 差异规模
+
+| 清点项 | AVA2 | 40250 | 说明 |
+|---|---:|---:|---|
+| 客户端 `.h/.cpp/.c/.py` 文件 | 738 | 651 | 递归清点,含备份与非活动代码 |
+| 同相对路径文件 | 639 | 639 | 其中 150 个只归一化 CRLF 后完全相同,489 个不同 |
+| AVA2 独有源码文件 | 99 | — | 含新增功能、反作弊、Discord、备份文件,不是 99 个功能 |
+| root 顶层 Python 文件 | 162 | 74 | AVA2 新增 88 个同名比较中不存在的文件,含旧版/备份脚本 |
+| AVA2 uiscript Python 文件 | 166 | — | 递归统计;未用此数量推断全部界面已启用 |
+| 服务端 `.h/.cpp` 文件 | 571 | 513 | 442 个同路径文件,其中 120 个归一化后相同、322 个不同;含第三方 Lua 头文件 |
+
+文件不同包括注释、编码、功能分支和实质逻辑;不能将 489/639 当成功能变化比例。机器可读清单在 `build/ava2-comparison`。
+
+## 2. 底层架构:延续旧引擎,没有升级到新引擎
+
+| 项目 | 原始 40250 | AVA2 | 当前 metin2-client |
+|---|---|---|---|
+| 客户端平台 | Windows C++ | Windows C++ | macOS/Android arm64 |
+| 图形接口 | Direct3D 8 | 仍引用 Direct3D 8 | SDL3 + Vulkan,macOS 经 MoltenVK |
+| Python | 2.7 系列 | 活动 ScriptLib 引用 Python2,头文件为 2.7.6 | 内嵌 2.7.18 |
+| 构建 | Windows 工程 | Win32 工程,v142 工具链 | CMake / Android Gradle |
+| 客户端二进制 | 原版 Windows 路线 | `bin_ava2.exe` 实测 PE32 Intel 80386 | 当前产物 arm64 64 位 |
+| 服务端部署 | 原版 FreeBSD 路线 | README 指定 FreeBSD 11 x32 + MySQL 5.6;game Makefile 为 g++11、`-m32`、C++14 | 现有 203 为已适配 40250 服务端 |
+
+AVA2 目录额外包含 Python22(2.2.3),但当前 ScriptLib 实际 include 指向 Python2(2.7.6);不能据目录名误认活动解释器是 2.2。工程叫 VC90/VC100 也不能直接认定当前工具集版本,活动 vcxproj 里是 v142。
+
+证据:[EterLib/StdAfx.h](); [ScriptLib/StdAfx.h](); [UserInterface/UserInterface_VC90.vcxproj](); [game/src/Makefile]()
+
+## 3. 协议:基础编号已经不同
+
+| 封包 | 40250 | AVA2 |
+|---|---:|---:|
+| CG_ATTACK | 2 | 225 |
+| CG_CHAT | 3 | 231 |
+| CG_ITEM_DROP | 12 | 224 |
+| CG_USE_SKILL | 52 | 233 |
+| CG_FISHING | 82 | 222 |
+| CG_STATE_CHECKER | 206 | 94 |
+
+上述 AVA2 attack/chat/skill/state 数字在其服务端 packet.h 中也有对应定义。这不是只有客户端 UI 的改动。
+
+原始头文件文字扫描:AVA2 找到 267 个显式赋值的 CG/GC 名称,40250 为 217;同名 6 个编号改变,AVA2 独有名称 51 个。此统计含条件关闭的声明,而且不能替代预处理后的实际封包注册表。
+
+新增代表:离线商店 CG57/58、商店搜索 CG143、洗属性 CG171、肩带 CG211、副本信息 CG213、掉落信息 CG215、合成 CG216、日历 CG220、战令 CG230、批量拾取 CG235;相应新增 GC 返回包。关闭的 combat zone/CShield/element spell 也有声明,不能全部当成活动功能。
+
+加密同样不同:40250 `ServiceDefs.h` 开启 `_IMPROVED_PACKET_ENCRYPTION_`;AVA2 客户端与服务端 service.h 都将它注释掉,AccountConnector 中保留并选择旧的 `SetSecurityMode` 分支。当前跨平台项目使用的 DH2/CTR 路线不能原样接 AVA2。
+
+证据:[UserInterface/Packet.h](); [UserInterface/Packet.h](); [EterBase/ServiceDefs.h](); [EterBase/ServiceDefs.h](); [UserInterface/AccountConnector.cpp]()
+
+## 4. 相同编号的封包,也可能完全不同
+
+| 结构/常量 | 40250 | AVA2 | 影响 |
+|---|---|---|---|
+| `TPacketGCPoints.points` | Windows 32 位 long | long long | 属性数组元素变 8 字节 |
+| `TPacketGCPointChange` | amount/value 为 long | amount/value 为 long long | 同名同编号不能照原长度分帧 |
+| `TPacketGCItemSet2.count` | BYTE | DWORD | 物品数量字段扩宽 |
+| 物品 socket 数组 | 3 | 8 | 结构长度、特殊物品 socket 索引变化 |
+| 物品属性数组 | 7 | 15 | 网络/数据库/角色物品结构变化 |
+| 物品附加字段 | 无 AVA2 扩展 | glove randomAttr[7]、自定义 name[25] 等 | 依启用开关继续变长 |
+| `TPacketGCDead` | header + vid | 增加复活类型数组 | 死亡/复活处理变化 |
+| 每账号角色 | 4 | 开关启用后 5 | 选角数组与登录成功包改变 |
+| 金钱/交易/商店价格 | 32 位相关字段 | 多处 long long | 必须连同 DB、Python 接口和格式化迁移 |
+
+**15 个属性数组不等于每件装备能正常洗出 15 条普通属性**:AVA2 仍定义普通 5 条、稀有 2 条,另外扩展槽的用途要按对应系统检查。**8 个 socket 也不等于能镶 8 颗石头**;唯一物品计时等也使用 socket。
+
+证据:[UserInterface/Packet.h](); [UserInterface/Packet.h](); [UserInterface/GameType.h](); [UserInterface/GameType.h](); [UserInterface/Packet.h]()
+
+## 5. 背包、装备与角色成长
+
+| 项目 | 原始 40250 | AVA2 |
+|---|---|---|
+| 背包 | 2 页、90 格 | 开启扩展,4 页、180 格 |
+| 特殊收纳 | 原版背包/龙魂/腰带等 | 新增专门收纳窗口与特殊库存常量 |
+| 装备 | 原版身体/武器/首饰等,预留 32 槽 | 肩带及肩带时装、武器/坐骑时装、手套、护符、宠物槽、发光槽、坐骑皮肤等 |
+| 服务端装备槽上限 | 32 | 新增槽后枚举至 36;需要核对客户端映射,不可依注释复制 |
+| 普通等级源码上限 | 120 | 250 |
+| 征服者等级 | 无此分支 | 宏开启,源码上限 30,Sungma/Yohara 相关字段 |
+| 龙魂 | 原版等级体系 | 增加神话等级、洗属性和效果控制 |
+| 货币上限常量 | 2,000,000,000 | 2,000,000,000,000,000LL |
+| 技能 | 原版 | 7/8 技能扩展、额外元素相关字段 |
+
+这里是源码常量,不代表打包服实际经验表、任务或地图支持玩家练到 250。当前 metin2-client 已是 5 页、225 格,**AVA2 的默认 4 页并不是对当前项目的背包容量升级**。
+
+客户端 `CItemData::WEAR_MAX_NUM` 仍显式写 32,但服务端 wear 枚举新增项后到 36,客户端也存在独立的新装备计数/槽映射。此处需要专项逐槽核对;静态看到差异不能直接宣称已产生越界。
+
+证据:[UserInterface/GameType.h](); [common/length.h](); [common/length.h](); [GameLib/ItemData.h](); [common/length.h]()
+
+## 6. 新增玩法系统及接入证据
+
+下表比较的是原始 40250;现有项目自定义扩展另算。
+
+| 系统 | AVA2 证据 | 状态/边界 |
+|---|---|---|
+| 离线商店、跨商店搜索 | 客户端 PythonPrivateShopSearch / uiofflineshop;服务端 offlineshop_manager/search;DB ClientManagerOfflineshop | 客户端/服务端宏开启,必须迁移 DB 表和协议 |
+| 自动洗属性 switchbot | PythonSwitchbot、uiswitchbot;服务端 new_switchbot;CG/GC171 | 有真实编译接入,不是只有脚本 |
+| 5+2 属性加成操作、属性锁定/复制 | __BL_67_ATTR__、ENABLE_LOCK_ATTR、ENABLE_COPY_ATTR_ITEM,uiAttr67Add | 不等于扩展数组每一槽都有普通洗属性入口 |
+| Wiki / 掉落查询 / 宝箱预览 | PythonWiki、PythonDropItem、uiwiki、uiChestDrop,target/drop 包 | 依赖资料和服务端返回,不是界面贴图即可 |
+| 模型预览 | CPythonRenderTargetModule、EterLib CRenderTarget、uirendertarget | 需要离屏渲染、相机和资源绑定 |
+| 副本资料/计时/排名 | PythonDungeonInfo、dungeon_info、uidungeoninfo | 服务端和 Python 互相依赖 |
+| 战令 Battle Pass | PythonBattlePass、uibattlepass、battlepass、questlua_battlepass | 宏开启并加入构建 |
+| 生物学家任务 UI | biolog、uibiyolog / uiprofessionalbiolog | 扩展任务及网络/UI 配合 |
+| 宠物成长、孵化、喂养、技能 | New_PetSystem、PythonSkillPet、多个宠物 UI | 比原版 PetSystem 更丰富;包里也有 old/copia 版本,不能全算启用 |
+| 肩带吸收/时装 | PythonSash、uisash、服务端 __SASH_SYSTEM__ | 增加物品、装备槽和封包 |
+| 日历、活动、帝国战争 | char_event_calendar、new_events、uicalendar/uikingdomswar | 多种活动实现并存,需确认实际入口 |
+| 小游戏 | fishgame、Catch King,对应脚本/包/宏 | 普通钓鱼也改了包头 |
+| 新副本内容 | Meley、Zodiac、船防御相关宏及源码/资源 | 静态存在,不等于每个副本任务流程已实测 |
+| 便捷操作 | 换线、批量精炼、合成改版、反经验、自选技能、装扮隐藏、装备切换、排序 | 有独立宏/源码/脚本,迁移复杂度不同 |
+| Discord | discord_rpc、ENABLE_DISCORD_RPC | 活动宏开启,但 app ID 配置值为 0,不能称现成可用集成 |
+| 银行/支援/狼人与战斗区 | 代码或脚本存在 | 开关不一致或关闭,不能列为无条件可用功能,见后文 |
+
+证据:[UserInterface/Locale_inc.h](); [UserInterface/UserInterface.cpp](); [UserInterface/UserInterface_VC90.vcxproj](); [game/src/Makefile]()
+
+## 7. 与近期自动狩猎问题直接相关的差异
+
+### 7.1 敌对怪物碰撞被忽略
+
+AVA2 启用 `ENABLE_MOBS_WITHOUT_COLLISIONS`。`CActorInstance::IsIgnoreCollision` 对 enemy 返回 TRUE,也对特定 race 和 campfire 返回 TRUE;InstanceBaseBattle 的角色碰撞检测在这个返回值为真时跳过该 actor。
+
+这会让客户端移动不被敌对怪群实体碰撞阻挡,是更改基础碰撞规则;我们的当前优化保留碰撞,通过近身清怪、拾取抢占和停止补拉解围。这两个方案的手感与语义不同,不能将 AVA2 的开关称作同一套自动狩猎修复。它也不代表忽略墙、地图阻挡或所有服务器校验。
+
+### 7.2 自动拾取是批量协议扩展
+
+AVA2 的 `CPythonPlayer::SendPickUPItemPacket` 收集物品 VID,500 ms 节流、过滤归属后调用 `SendItemsPickUpPacket`,新增 CG235。它不同于当前项目基于单个物品、路径、预约和超时的拾取。
+
+本次没有找到与当前项目同名的完整 `AutoHunt` 状态机;原版已有的 `__Update_AutoAttack` 不能当成新增完整挂机系统。自动拾取、技能流派选择、自动洗属性也不等于具备当前自动狩猎的引怪、寻路、恢复和配置能力。进一步核对 `uiautoskill.py`:其窗口仅在技能流派尚未选择时打开,按钮发送选择流派命令;`ENABLE_AUTO_SELECT_SKILL` 不是自动循环施法。
+
+证据:[GameLib/ActorInstanceCollisionDetection.cpp](); [UserInterface/InstanceBaseBattle.cpp](); [UserInterface/PythonPlayer.cpp]()
+
+## 8. 资源、Python 与渲染
+
+- AVA2 活动 `USE_FOX_FS` 走 FoxFS API(FoxFS_Create/LoadA/GetA),包索引按每行一个包名读取;包里提供 FoxFS Windows `.lib`,本地 pack 名称也不走普通成对 EIX/EPK 清单。这是当前 EPK 加载器不能直接替换的依赖。
+- 存在大量已解包目录,因此可以挑选资源复用;没有必要为了导入一个模型就先移植 FoxFS。
+- pack_maker 清点得到 45 个资源目录、93,874 个文件,含重复/备份、工具或压缩文件,不是 93,874 个独立游戏资产。
+- 增加 Yohara/征服者地图、Zodiac、Meley、新宠物、装扮、离线商店、掉落查询、新选角等资源目录。目录存在不等于所有模型格式都经过当前 libgr2 验证。
+- Python root 仍用 Python 2 语法和 `app/net/player/item` 一类原版 C++ 模块,但接口已扩展;复制整个 root 会遇到缺少 C++ 方法、常量、封包和数据等问题。
+- ItemManager 支持 MIPX/MIPT 和结构自动检测,PythonNonPlayer 也有 mob_proto 结构兼容分支;这些是 proto 表结构转换能力,**不是服务器协议自动兼容能力**。
+- EterLib 仍 D3D8,新增 offscreen RenderTarget,用于 Wiki/商店/NPC 模型预览;大量闪光、特效、缩放和显示选项属于旧引擎扩展,没有证据表明换成现代渲染引擎。
+
+证据:[UserInterface/UserInterface.cpp](); [EterPack/EterPackManager.cpp](); [GameLib/ItemManager.cpp](); [system.py]()
+
+## 9. 数据库与文件包的区别
+
+`MYSQL56.tgz` 实际主要是 MySQL 数据目录备份,不是通用 SQL 导出:清点 252 个 `.frm`、193 对 `.MYI/.MYD`、5 个 `.ibd` 等。存在 `offline_shop_item/npc/search/log`、`battle_pass`、`new_petsystem`、`ranking_dungeon`、`date_dungeon`、`itemshop` 等表。
+
+因此不能简单把它当 `.sql` 导入现有 MySQL 8,更不能覆盖当前账号/人物/物品数据库。选移功能时应从源码 SQL 读写和隔离的原运行环境还原 schema,编写逐表迁移。当前版本没有导入、启动或验证该备份,表名清点不代表 schema 已校验。
+
+AVA2 item/proto/player 表还需要匹配 8 sockets、15 attrs、glove randomAttr、long long gold、conqueror 等结构。我们当前给“11”配置的 7 条属性和 +6 石头无需因比较而修改;本次未连接 203。
+
+## 10. 打包源码中的不一致与未确认项
+
+1. 客户端 `ENABLE_WOLFMAN_CHARACTER` 注释,服务端 CommonDefines.h 开启。该静态配置不一致值得检查,不能直接宣布狼人在提供的 exe 中可玩。
+2. 客户端 `ENABLE_MANAGER_BANK_SYSTEM` 开启,服务端对应宏注释。不能把银行当作已经两端一致启用的系统。
+3. 客户端 `ENABLE_SUPPORT_SYSTEM` 注释,服务端开启;角色支援代码/脚本存在,但入口和最终二进制需要核对。
+4. CShield 的宏关闭,但旧保护组件、相关静态库、反作弊源码仍在;移植时要检查活动构建,不宜按文件名一律迁移。
+5. 有 `oldrendertarget`、`*_old.py`、`- Copy.cpp`、`copia`、多套商城/宠物/离线商店实现。文件数量不能当系统数量。
+6. `Locale_inc.h` 有 `ENABLE_HIGHLIGHT_SYSTEM // disabled because its bug` 这样的注释与开关状态矛盾,表明注释不能替代实际预处理结果。
+7. 未证明提供的 Windows exe 与这份 vcxproj/宏是同一次构建;源码宣告功能与成品实际功能要区分。
+8. 未做双方 GR2/地图/EPK-FoxFS 格式全量交叉读取、逐技能数值比较、任务语料比较或性能测量;不能据系统更多推断更稳定、更快、更省电。
+
+证据:[UserInterface/Locale_inc.h](); [common/CommonDefines.h](); [UserInterface/Locale_inc.h](); [common/CommonDefines.h]()
+
+## 11. 对当前项目的迁移建议
+
+| 目标 | 建议 | 主要依赖 |
+|---|---|---|
+| 只想改善打怪体验 | 保留当前主线;按需要评估是否采用“怪物无碰撞”作为单独玩法变更 | 碰撞规则和真服移动验证 |
+| 换地图、怪物、特效、装扮 | 先离线导入少量已解包资源 | 资源读取、race/MSM/MSA、proto、挂点 |
+| Wiki、宝箱掉落、副本资料 UI | 适合分模块引入 | 服务端查询、Python 接口、UI 和数据 |
+| 模型预览 | 可移植,改写 D3D 离屏渲染为现有 Vulkan 目标 | 相机、RT、材质和 UI 合成 |
+| 换线、批量精炼、自动洗属性 | 分别立功能,不搬整个 AVA2 root | 服务端处理、封包、状态安全与 UI |
+| 离线商店、宠物成长、肩带、征服者/手套/护符 | 重型系统迁移,需要独立设计和验收 | 协议、DB、数值、资源、逻辑和 UI 全链 |
+| 直接连接 AVA2 服务器 | 单独协议后端或专用分支 | 旧加密、包号/长度、角色/物品/金钱、Python 接口 |
+| 将 AVA2 当作新底座替换当前项目 | 不建议直接替换 | 会重新承担 Win32→arm64、专有库、运行时和完整回归成本 |
+
+最合理的用法:把 AVA2 当作功能及资源参考库,选择少数实际需要的系统逐个移植。若目标是完整兼容 AVA2,按“另一套客户端/服务端协议项目”估算,不能当成替换几个脚本的升级。
diff --git a/docs/D2R-MOBILE-REFERENCE.md b/docs/D2R-MOBILE-REFERENCE.md
new file mode 100644
index 00000000..4ba3a151
--- /dev/null
+++ b/docs/D2R-MOBILE-REFERENCE.md
@@ -0,0 +1,20 @@
+# D2R Mobile 可参考的客户端设计
+
+检查日期:2026-10-14。来源:[jhaizhou-ops/d2r-mobile](https://github.com/jhaizhou-ops/d2r-mobile),版本 `685d3ddef8bf75768fd4c51228566f2e8d2a5182`。
+本次只修改 metin2-client;下表除技能布局外均为后续建议。
+
+| 优先级 | 参考设计与源码 | Metin2 可采用的方式 |
+| --- | --- | --- |
+| 已应用 | [单层技能弧与独立移动区](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/ios/Sources/dm_touch_controls.c) | 原项目四个副技能,本客户端扩为六个同半径快捷槽;保留大攻击键与浮动摇杆,统一绘制和命中几何。 |
+| 高 | [手势取消和状态身份检查](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/ios/Sources/dm_touch_controls.c) | 切后台、断线、切场景、尺寸变化时明确收回手指与持续攻击;给延迟触摸动作绑定场景/手势序号,避免旧动作在新场景执行。先补系统取消事件的覆盖。 |
+| 高 | [物品手势](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/ios/Sources/dm_touch_controls.h) | 在现有单击操作面板、长按使用、拖放基础上,扩大商店/仓库落点、增加拖动目标提示;每一步沿用原客户端请求与确认流程。 |
+| 高 | [触摸脚本和结果断言](https://github.com/jhaizhou-ops/d2r-mobile/tree/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/verify/touchcases) | 为摇杆+攻击+技能多指组合、取消手势、背包拖动建立可重放触点序列,同时检查发包和物品结果。复用现有离线登录服务器,避免只凭截图判断操作正确。 |
+| 中 | [底部 HUD 重排](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/ios/NativeHUD/README.zh-CN.md) | 以 Metin2 自有生命/法力/经验组件和资源重排为紧凑底栏,保留真实数据;药水、背包等常用入口按手指尺寸统一排布。此次只给现有底栏预留空间。 |
+| 中 | [图形状态缓存和管线预热](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/gfx/README.zh-CN.md) | 在现有 Vulkan 管线缓存上采集真实场景常用状态,预热进地图必用组合;用首次入图最长帧和持续温度验证收益。其 NVN→Metal 实现不能直接替换本项目 D3D8→Vulkan 路径。 |
+| 中 | [技能状态和显示分离](https://github.com/jhaizhou-ops/d2r-mobile/blob/685d3ddef8bf75768fd4c51228566f2e8d2a5182/dm/ios/Sources/dm_touch_game_state.h) | 图标、冷却、数量来自一致的状态快照;切角色/地图后使旧状态失效。本项目已有图标和冷却桥接,可补生命周期一致性。 |
+
+其 README 还展示了手柄/触摸自动切换、30fps 省电、掉落过滤、高价值掉落震动与屏外目标指引。这些适合后续客户端体验迭代:优先接入已有帧率策略、掉落显示和小地图系统,避免重复实现已有功能。
+其中定向拖动施法应先按 Metin2 的各技能目标类型设计,不能把另一款游戏的施法模型直接照搬。
+
+该项目是离线 Switch 程序的 Apple 平台兼容层;二进制改写、NVN 接口和离线存档事务与本项目在线客户端架构不同。
+它公布的帧率、耗电和启动时间是该项目的测量结果,不能据此推算 Metin2 的性能。建议借鉴分层、缓存、触控和验证方法,再在本客户端实测。
diff --git a/docs/MOBILE-TOUCH-OPTIMIZATION.md b/docs/MOBILE-TOUCH-OPTIMIZATION.md
new file mode 100644
index 00000000..a86725fd
--- /dev/null
+++ b/docs/MOBILE-TOUCH-OPTIMIZATION.md
@@ -0,0 +1,109 @@
+# 原生移动端触控优化
+
+2026-09-30 首批优化,适用于 SDL3 / Vulkan 客户端。移动端 UI 由 PORT 模块提供,资源中的 40250 Python 脚本不改写。
+
+## 已实现
+
+- 六个快捷键围绕攻击键形成单层弧线;自动狩猎放在弧线外侧。按钮、间距和摇杆随 UI 尺寸一起调整。
+- 保留真实物品 / 技能图标和数量,增加技能冷却秒数、未学习遮罩和激活技能绿环。状态来自 CPythonPlayer;技能可否释放仍由原有逻辑判断,不把这些展示状态当作完整施法条件。
+- 根据 SDL 窗口显示倍率与实际像素高度,把 48dp 换算为 UI 坐标下的触摸目标。战斗按钮直径和物品操作行使用这一目标;现有背包格子与其他原版窗口尚未整体重排。
+- 单击背包/装备显示原物品说明,双击调用原使用/穿戴/卸下方法;不再弹出移动端操作面板。
+- 拖动调用原有物品移动和快捷槽绑定流程;拖到 NPC 商店背景或货架先显示出售确认。双击和拖放核对页码、物品编号、数量、背包可见性与确认状态,失效手势取消;长按继续沿用原右键使用行为。
+- 修复每帧重排导致浮动摇杆起点回到默认位置;同一按钮和 UI 手势保留原手指所有权,避免另一根手指覆盖。
+
+实现入口:`src/host/touch_controller.h`、`src/host/vk_frame.cpp`、`src/platform/ScriptLib/PythonBoot.cpp`、`MobileInventoryScript.inc`。
+
+## 验证
+
+```sh
+cmake --build build/release --target mt_native_render -j8
+cmake --build build/native --target port_login_flow_test -j8
+MT_TEST_MOBILE=1 ctest --test-dir build/native -R '^port.login_flow$' --output-on-failure
+ctest --test-dir build/native -R '^port.login_flow$' --output-on-failure
+./android/build.sh --no-deploy
+```
+
+移动模式的离线登录测试额外覆盖摇杆跨帧保留起点、真实触点单击说明、双击穿脱、拖动换位、出售确认/取消与翻页失效。桌面模式继续覆盖既有登录、战斗、商店、仓库流程。
+
+## 后续工作
+
+- 可保存的按钮位置 / 大小、自定义固定 / 浮动摇杆和视角灵敏度。
+- 目标切换、就近 NPC 交互与拾取入口。
+- 背包格子及商店、任务窗口的完整手机布局、拖放目标高亮。
+- Android 真机验证多指连续战斗、挖孔安全区、屏幕密度和长时间手持体验。构建通过及桌面截图不等于真机手感验收。
+
+## 2026-10-14:参考 D2R Mobile 的单层技能弧
+
+参考仓库 [d2r-mobile](https://github.com/jhaizhou-ops/d2r-mobile),检查版本 `685d3ddef8bf75768fd4c51228566f2e8d2a5182`。
+参考入口是 `dm/ios/Sources/dm_touch_controls.c` 的 `dm_tc_layout_make`:主操作键旁的单层技能弧,以及移动区与战斗区分开。
+本次采用布局思路,未引入其游戏资源或兼容层代码。
+
+- 保留左下浮动摇杆、右下大攻击键,六个快捷槽全部在同一条弧线上,不拆成两层,也不将第五、第六槽移到底部。
+- 六槽相对攻击键的距离相同,相邻角度相差 24 度。间距覆盖实际命中使用的 1.25 倍半径,避免相邻命中区域交叠。
+- 整组上移,为原有生命、法力、经验和菜单底栏留出空间。自动狩猎在弧线左侧独立放置,保留点击切换与长按设置。
+- 六槽编号、图标、数量、冷却、拖入绑定和原有使用逻辑保持对应。摇杆头与底盘同步缩放。
+- `--mobile` 在 Mac 验收时同样隐藏原桌面快捷栏,与 Android 展示路径一致。桌面模式沿用原布局。
+- 几何统一放在 `src/host/touch_layout.h`,渲染和触摸命中共用计算结果。
+
+检查命令:
+
+```sh
+cmake --build build/native --target host_touch_layout_test port_login_flow_test -j8
+ctest --test-dir build/native -R '^host.touch_layout$' --output-on-failure
+MT_TEST_MOBILE=1 ctest --test-dir build/native -R '^port.login_flow$' --output-on-failure
+ctest --test-dir build/native -R '^port.login_flow$' --output-on-failure
+JOBS=8 ./android/build.sh --no-deploy
+```
+
+几何检查覆盖 640×360、800×600、超宽横屏、放大触摸目标和平板,检查安全边界、单层顺序、最小触摸尺寸和命中间距。
+Mac 移动模式离线场景渲染截图在 `build/touch-layout-validation/mobile.png`。Android 真机手持与多指手感仍需实测。
+
+其他值得借鉴的设计及适配优先级见 [D2R-MOBILE-REFERENCE.md](D2R-MOBILE-REFERENCE.md)。
+
+## 2026-10-14:按用户截图调整 HUD 分区
+
+参考用户提供的横屏实机截图,在上一版单层技能弧上继续调整。实现仅在触摸模式安装的 PORT 模块 `src/platform/ScriptLib/MobileHUDScript.inc`,不修改原资源包里的 Python 文件。
+
+- 左上:职业头像、角色名、等级、生命/法力数值及原版红、蓝、耐力三条;有效能量加成显示剩余分钟,状态图标放在下方。所有数值来自当前玩家数据,没有收到最大值时显示 `--`。
+- 右上:角色、背包、好友、设置四个圆形入口;小地图向下留出菜单行。头像也可打开角色面板。
+- 左侧:任务、帮助、聊天,沿用现有界面动作。矮屏或触摸目标放大时改为横排,避开摇杆。原来的小帮助图标不重复显示。
+- 左下:浮动摇杆;右下:大攻击键及六技能单层弧;空槽保留编号,绑定后显示原图标和数量/冷却。
+- 底部:第 7、8 个本地快捷槽,可绑定药水等物品。点击和拖入绑定与原快捷栏一致;没有额外生成物品或修改物品效果。
+- 以深棕底和金铜色圆边统一菜单及战斗控件,聊天消息移到摇杆右侧。原桌面底栏在触摸模式隐藏,菜单通过原有回调打开。
+- HUD 显隐接入原任务栏的 Show/Hide,离开游戏时恢复原方法并释放回调。HUD 容器本身不可命中,只有实际按钮接收点击,移动区域不会被整屏容器挡住。
+
+验证文件在 `build/mobile-hud-validation/`:`mobile.png` 是 Mac 移动模式的离线场景截图,截图中的生命/法力使用测试钩子设置以展示条形效果,非真实服务器状态;`mobile-test.log` 与 `desktop-test.log` 为回归记录。
+`host.touch_layout` 同时检查新增物品快捷槽与技能、攻击、自动狩猎的扩展命中范围不交叠。
+Android 产物为 `android/app/build/outputs/apk/debug/app-debug.apk`,未自动安装到手机;真机多指和长期手持体验仍需实测。
+
+### 经验球与原版状态栏样式恢复
+
+用户要求四颗经验球留在左下、头像及红蓝耐力条沿用之前的样式。左上改为复用原 TaskBar 的 `Gauge_Board`:原边框、HP/SP 动画贴图、恢复覆盖层和黄色 ST 条整体重挂到移动 HUD,职业头像仍取原角色资源。状态变化调用原 `RefreshStatus` / `RefreshStamina` 更新,保留原计算逻辑。
+
+`EXP_Gauge_Board` 与四个 `EXPGauge` 点整体移到左下角;继续使用原版按四分之一填充经验球的 `SetExperience`,旁边增加经验百分比,移除顶部新画的经验细条。隐藏/显示 HUD、屏幕尺寸变化和离开游戏的父节点恢复都覆盖这些组件。
+
+最新预览 `build/mobile-hud-validation/mobile.png` 中的 850/1000 HP、420/600 SP、70/100 耐力和 62.50% 经验来自离线测试钩子,用于检查颜色、填充和位置。
+
+后续微调:头像外圈缩为 34 个 UI 像素,与原版红蓝耐力三条的外框整体同高;裁掉旧状态底图左侧的大球装饰。移动 HUD 将聊天窗口查看和输入状态的背景色设为透明,保留消息文字、滚动和输入功能,离开游戏恢复原实例颜色。已检查离线渲染预览并重新构建 Android APK。
+
+## 物品手势调整:单击说明、双击穿脱、拖放移动/出售
+
+- `MobileInventoryScript.inc` 移除单击操作面板。单击只显示原 Tooltip,不把物品挂到指针;双击使用原 `UseItemSlot`,穿戴与卸下仍由原客户端校验并向服务端发请求。
+- 双击核对第一次点选的窗口、页码、全局格子、物品编号、数量及时间。换页、换物品、出现确认窗口后不执行旧双击。
+- 拖动仍先走原 `SelectItemSlot` 拿起,再交给原背包、装备或快捷槽的目标处理。NPC 商店背景/标题/货架均可作为出售落点;通过实际拾取到的窗口及祖先识别,遮挡在上面的其他窗口不会触发出售。
+- 商店出售调用原背包 `__SellItem`,保留禁止出售检查、确认/取消和确认时物品身份复核;不直接绕过确认发出售包。私人商店不增加此入口。
+- 开着背包/商店时,窗口绘制在战斗覆盖层前面,命中也优先窗口;HUD 菜单回到普通 UI 层,避免遮住装备格。
+- 不修改服务端,也不绕过商店开启时不能卸装备等已有规则。长按仍使用原右键行为;拆分堆叠和一键整理未在此次增加。
+
+客户端内的离线测试夹具增加装备穿/脱和物品换位回复,只在 `MT_TEST_MOBILE` 测试环境启用。回归覆盖单击无使用请求、双击穿戴/卸下的实际格子变化、拖动换位、换页取消、出售取消保留物品以及确认后物品/金币变化。运行日志:`build/mobile-hud-validation/gestures-test.log`。
+
+## D2R 默认摇杆与技能外观(2026-10-14)
+
+参照 d2r-mobile `dm/ios/Sources/DmTouchOverlay.m`(参考检出 `685d3ddef8bf75768fd4c51228566f2e8d2a5182`)的颜色、透明度和构图,用客户端绘图命令独立绘制,不引入参考项目的游戏贴图。
+
+- 技能使用深色圆底、古铜外圈和暗色内圈;闲置底色 alpha 0.48、外圈 0.54,按住恢复至 0.92 / 1.0。
+- 空技能槽显示小铜色加号;已绑定的 Metin2 图标保持清晰,占直径 64%,保留冷却、激活和数量提示。
+- 浮动摇杆改为闲置淡圆环、带双圈和四角星的摇杆头;操作时增强透明度并显示朝向弧,移除十字刻度和蓝色连线。摇杆头半径为 23 × UI 缩放。
+- 圆底使用不重叠扫描条绘制,避免原先矩形交叠形成十字暗块。保留原命中区域、浮动原点、移动计算和六技能单层弧线。
+
+验证:`host.touch_layout` 与 `MT_TEST_MOBILE=1` 的 `port.login_flow` 均通过(57.16 秒),浮动原点测试改为检查实际底圈起点。macOS 渲染预览为 `build/mobile-hud-validation/d2r-style.png`;Android APK 构建成功,未安装到真机。日志为 `build/mobile-style-test.log`、`build/mobile-style-android-build.log`。
diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md
index d6b29a64..b8574e37 100644
--- a/docs/PORT-PLAN.md
+++ b/docs/PORT-PLAN.md
@@ -246,3 +246,18 @@ python3 .agents/skills/metin2-40250-parity-audit/scripts/port_map.py check
```
然后按第 4 节"下一步"继续,每完成一步在第 4、6 节更新状态并提交。
+
+2026-10-14 用户授权扩展:小地图旁增加“研究”入口,适配 AVA2 风格生物学家窗口。
+客户端模块 `BiologScript.inc` 和 `tools/server/biolog/` 复用当前服务器 30~94 级收集任务,保留 NPC 状态、原冷却和奖励,不迁移成 AVA2 的 `bio.*`。
+新增远程接取/逐份提交/任务石/领奖入口,药水为原任务 71035;92/94 级奖励继续通过原任务对话选择。
+203 已部署该扩展,13 个 game cores 恢复;备份 `/root/backup-biolog/20261015-023604`。
+专用测试角色已验证接取、扣料、冷却拦截、重登一致;528 组原 NPC/远程 Lua 对照和桌面/触摸回归通过。详见 `tools/server/biolog/README.md`。
+
+2026-10-14 后续用户明确要求完全使用 AVA2 生物学家实现,且不考虑旧角色。已替换此前的原任务远程适配:
+使用 AVA2 原 do_bio 和 13 阶段(30~105 级)配置,独立 bio.* 进度;71035/30250 道具、选择奖励、70028 重选、22 项原研究商店及新增研究属性战斗效果接入。
+203 备份 `/root/backup-biolog-ava2/20261015-030954`;旧 collect_quest 事件已停用,不迁移原进度、不清除旧角色历史属性。
+新增物品与图标通过 Client/pack/biolog.eix/.epk 和首位 Index 注册提供,升级客户端时须同步此资源补丁。
+AVA2 生物学家最终验收:6,674 组源实现对照、实际战斗计算、桌面/触摸回归,以及联机提交/辅助道具/选奖/兑换/重选/重登/全部完成检查均通过。
+70028 原型按 AVA2 改为 ITEM_QUEST,资源模板总数 29;完成角色的历史选项也会返回,支持继续重选。
+最新备份:物品 `/root/backup-biolog-ava2/20261015-031709`,game `/root/backup-biolog-ava2-game/20261015-032329`。
+Mac 和 Android 更新包在 dist/*AVA2Bio-20261014.zip,Android 包含 APK 与必需的 biolog 资源补丁。
diff --git a/scripts/build_hd_ui.py b/scripts/build_hd_ui.py
index 639d9186..27d1a99a 100644
--- a/scripts/build_hd_ui.py
+++ b/scripts/build_hd_ui.py
@@ -81,6 +81,10 @@ def read_entry(keys, epk: Path, e: dict) -> bytes:
def wanted(name: str, locale: str) -> bool:
+ # SCREEN waypoint sprites need their original black RGB under alpha=0.
+ # The shared minimap atlas must not pass through alpha-edge colour bleeding.
+ if name == "d:/ymir work/ui/minimap.dds":
+ return False
if not name.endswith(IMAGE_EXTS):
return False
return (name.startswith("d:/ymir work/ui/") or name.startswith("icon/")
diff --git a/scripts/build_zh_locale.py b/scripts/build_zh_locale.py
index 8b94f2eb..83e5037f 100644
--- a/scripts/build_zh_locale.py
+++ b/scripts/build_zh_locale.py
@@ -348,10 +348,13 @@ def build(en: Path, zh: Path, output: Path, client_base: Path,
names_dir / "mob_names_zh_overlay.txt")
# item_limits_server.tsv: item limits changed on our server, so the tooltip matches what it enforces.
limits = names_dir / "item_limits_server.tsv"
+ if (names_dir / "item_stones_server.tsv").is_file() and not limits.is_file():
+ raise ValueError("stone overlay requires the item limits table argument")
result = subprocess.run(
[str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"),
str(item_names), str(mob_names), str(locale_dir / "item_proto"),
- str(locale_dir / "mob_proto")] + ([str(limits)] if limits.is_file() else []),
+ str(locale_dir / "mob_proto")] + ([str(limits)] if limits.is_file() else [])
+ + ([str(names_dir / "item_stones_server.tsv")] if (names_dir / "item_stones_server.tsv").is_file() else []),
check=True, capture_output=True, text=True,
)
report["proto"] = {
@@ -383,6 +386,41 @@ def build(en: Path, zh: Path, output: Path, client_base: Path,
return report_path
+def build_biolog(output: Path, pack_tool: Path):
+ """AVA2 biology is part of the current client; rebuilding the locale must retain its item patch."""
+ import importlib.util
+ import tarfile
+ root = REPO / "tools/server/biolog"
+ ava = REPO.parent / "AVA2"
+ archive = ava / "2. Backup SF+SRC and SQL/FILES+SRC.tgz"
+ if not archive.is_file():
+ raise ValueError("AVA2 biology requires the local FILES+SRC.tgz reference")
+ proto_tool = tool("biolog_proto_patch")
+ if not proto_tool.is_file():
+ raise ValueError("build biolog_proto_patch before generating the Chinese client")
+ stage = output.parent / "biolog"
+ stage.mkdir(parents=True, exist_ok=True)
+ proto = stage / "ava2-item-proto.txt"
+ with tarfile.open(archive, "r:gz") as source:
+ member = source.extractfile("home/srv1/db/item_proto.txt")
+ if member is None:
+ raise ValueError("AVA2 item table missing")
+ proto.write_bytes(member.read())
+ spec = importlib.util.spec_from_file_location("biolog_assets", root / "make_assets.py")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ module.prepare(proto, ava / "1. Client DEV/pack_maker", output.parent / "locale-src/zh",
+ stage, proto_tool.resolve(), pack_tool.resolve())
+ for extension in ("eix", "epk"):
+ shutil.copy2(stage / ("biolog." + extension), output / "pack" / ("biolog." + extension))
+ index = output / "pack/Index"
+ lines = index.read_bytes().replace(b"\r\n", b"\n").splitlines()
+ if b"biolog" not in lines:
+ lines[1:1] = [b"*", b"biolog"]
+ index.write_bytes(b"\r\n".join(lines) + b"\r\n")
+ return {"stages": 13, "item_templates": 29, "pack": "biolog", "first_priority": True}
+
+
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--english", type=Path, default=DEFAULT_EN)
@@ -395,6 +433,9 @@ def main():
args = parser.parse_args()
report_path = build(args.english, args.chinese, args.output, args.client_base,
args.proto_tool, args.name_tables, args.pack_tool)
+ report = json.loads(report_path.read_text())
+ report["biolog"] = build_biolog(args.output, args.pack_tool)
+ report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
print(f"Built {args.output / 'pack/locale_zh.eix'} (.epk), registered in pack/Index")
print(f"Report: {report_path}")
diff --git a/scripts/package_mac_app.sh b/scripts/package_mac_app.sh
index a1622dee..2036f228 100755
--- a/scripts/package_mac_app.sh
+++ b/scripts/package_mac_app.sh
@@ -12,6 +12,7 @@ bin=$build/src/host/mt_native_render
[[ -d "$client/pack" ]] || { echo "missing Client/pack: $client" >&2; exit 1; }
[[ -f "$pak" ]] || { echo "missing HD UI archive: $pak (run scripts/build_hd_ui.py first)" >&2; exit 1; }
python3 "$repo/scripts/verify_hd_ui.py" "$pak"
+signing_identity=${MT_CODESIGN_IDENTITY:--}
version=$(git -C "$repo" describe --always --dirty 2>/dev/null || echo dev)
mkdir -p "${out:A:h}"
stage=$(mktemp -d "${out:A}.tmp.XXXXXX")
@@ -21,8 +22,11 @@ cp "$bin" "$stage/Contents/MacOS/mt_native_render"
cp "$build/src/host/native.vert.spv" "$build/src/host/native.frag.spv" "$build/src/host/python27.zip" "$stage/Contents/Resources/"
cp "$repo/branding/icon/macos/AppIcon.icns" "$stage/Contents/Resources/"
cp "$pak" "$stage/Contents/Resources/hd_ui.pak"
-# Clone private assets on APFS; fall back to an ordinary copy on other filesystems.
-cp -cR "$client" "$stage/Contents/Resources/Client" 2>/dev/null || ditto "$client" "$stage/Contents/Resources/Client"
+# Materialize source links so the bundle is portable; clone asset data on APFS.
+cp -cRL "$client" "$stage/Contents/Resources/Client" 2>/dev/null || {
+ mkdir -p "$stage/Contents/Resources/Client"
+ rsync -aL "$client/" "$stage/Contents/Resources/Client/"
+}
for lib in libvulkan.1.dylib libSDL3.0.dylib; do
dependency=$(otool -L "$bin" | awk -v name="$lib" '$1 ~ name "$" {print $1; exit}')
[[ -f "$dependency" ]] || { echo "cannot resolve $lib: $dependency" >&2; exit 1; }
@@ -72,11 +76,12 @@ cat > "$stage/Contents/Info.plist" <LSMinimumSystemVersion12.0
LSApplicationCategoryTypepublic.app-category.role-playing-games
NSHighResolutionCapable
+NSLocalNetworkUsageDescription连接局域网中的 Metin2 游戏服务器,查询频道状态并登录游戏。
PLIST
-for library in "$stage"/Contents/Frameworks/*.dylib; do codesign --force --sign - "$library"; done
-codesign --force --sign - "$stage/Contents/MacOS/mt_native_render"
-codesign --force --deep --sign - "$stage"
+for library in "$stage"/Contents/Frameworks/*.dylib; do codesign --force --sign "$signing_identity" "$library"; done
+codesign --force --sign "$signing_identity" --identifier org.metin2port.client.renderer "$stage/Contents/MacOS/mt_native_render"
+codesign --force --deep --sign "$signing_identity" "$stage"
codesign --verify --deep --strict "$stage"
# Publish only after validating the complete replacement bundle.
rm -rf "$out"
diff --git a/src/host/live_client.cpp b/src/host/live_client.cpp
index e2a271ea..fa2845cb 100644
--- a/src/host/live_client.cpp
+++ b/src/host/live_client.cpp
@@ -57,7 +57,6 @@ bool py_eval_true(const char* expr) {
return PythonBoot::Evaluate(expr, &res, &err) && (res == "True" || res == "1");
}
-#ifdef __ANDROID__
bool hide_mobile_taskbar_quickslots() {
return py_exec(
"_mobile_taskbar = _stream.curPhaseWindow.interface.wndTaskBar\n"
@@ -65,7 +64,6 @@ bool hide_mobile_taskbar_quickslots() {
" _mobile_quickslot.Hide()\n"
"_mobile_taskbar.GetChild('QuickSlotBoard').Hide()\n");
}
-#endif
#if defined(__APPLE__) && TARGET_OS_OSX
bool configure_macos_numeric_quickslots() {
@@ -305,10 +303,8 @@ int run_live_client(
}))
throw std::runtime_error("timed out waiting for GameWindow (server error: " + server.Error() + ")");
-#ifdef __ANDROID__
- if (!hide_mobile_taskbar_quickslots())
- throw std::runtime_error("failed to hide the desktop quick-slot bar on Android");
-#endif
+ if (PythonBoot::TouchInput() && !hide_mobile_taskbar_quickslots())
+ throw std::runtime_error("failed to hide the desktop quick-slot bar in touch mode");
#if defined(__APPLE__) && TARGET_OS_OSX
if (!configure_macos_numeric_quickslots())
throw std::runtime_error("failed to configure numeric quick slots on macOS");
diff --git a/src/host/texture_decode.cpp b/src/host/texture_decode.cpp
index d2ab4729..3f92d698 100644
--- a/src/host/texture_decode.cpp
+++ b/src/host/texture_decode.cpp
@@ -188,6 +188,11 @@ bool read_live_pack_texture(const std::string& vpath, std::vector&
}
bool read_hd_ui_texture(const std::string& vpath, std::vector& bytes) {
+ // The minimap atlas contains SCREEN-blended waypoint frames. Their black RGB
+ // background is significant even at alpha=0 (INVDESTCOLOR + ONE ignores alpha).
+ // HD alpha-edge colour bleeding destroys it; retain the original packed atlas.
+ for (const auto& candidate : pack_name_candidates(vpath))
+ if (candidate == "d:/ymir work/ui/minimap.dds") return false;
static auto pak = open_hd_ui_pak();
if (!pak || vpath.empty()) return false;
for (const auto& candidate : pack_name_candidates(vpath))
diff --git a/src/host/touch_controller.h b/src/host/touch_controller.h
index 3bbc95d9..352393e5 100644
--- a/src/host/touch_controller.h
+++ b/src/host/touch_controller.h
@@ -9,6 +9,7 @@
#include
#include "UIRenderCommands.h"
+#include "touch_layout.h"
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
#include "platform/ScriptLib/PythonBoot.h"
@@ -17,7 +18,7 @@
class TouchController {
public:
struct ButtonDef {
- int id; // 1 = attack, 2..7 = quick slots 1..6, 8 = auto hunt
+ int id; // 1 = attack, 2..7 = skills, 8 = auto hunt, 9..10 = bottom quick slots
int dik;
float x, y, radius;
const char* label;
@@ -38,6 +39,7 @@ public:
#endif
}
bool is_enabled() const { return enabled_; }
+ void set_minimum_touch_target(float logicalSize) { minimum_touch_target_ = std::max(48.0f, logicalSize); }
// Short vibration when a long press fires (the host wires it to the platform).
void set_haptic(void (*haptic)()) { haptic_ = haptic; }
@@ -54,13 +56,14 @@ public:
screen_h_ = height;
safe_inset_ = float(std::max(0, safe_inset));
// Position joystick on lower-left
- joystick_base_x_ = safe_inset_ + std::max(90.0f, float(width) * 0.12f);
- joystick_base_y_ = float(height) - std::max(90.0f, float(height) * 0.22f);
+ layout_buttons();
if (!joystick_active_) {
+ // Retain the floating origin while held; rendering updates screen size every frame.
+ joystick_base_x_ = safe_inset_ + std::max(joystick_radius_ + 24.0f, float(width) * 0.12f);
+ joystick_base_y_ = float(height) - std::max(joystick_radius_ + 24.0f, float(height) * 0.22f);
joystick_knob_x_ = joystick_base_x_;
joystick_knob_y_ = joystick_base_y_;
}
- layout_buttons();
}
// Called once per frame to maintain continuous analog movement
@@ -118,14 +121,19 @@ public:
const float px = norm_x * float(screen_w_);
const float py = norm_y * float(screen_h_);
const bool controls_visible = gameplay_controls_visible();
+ bool ui_hit = false;
+#ifdef MT_NATIVE_HAS_LIVE_CLIENT
+ ui_hit = PythonBoot::IsPointInsideActiveUI(int(px), int(py));
+#endif
// The mobile overlay owns only the movement and combat control regions.
- if (controls_visible) {
+ if (controls_visible && !ui_hit) {
const int btn_idx = find_button(px, py);
if (btn_idx >= 0) {
auto& btn = buttons_[btn_idx];
+ if (btn.finger_id >= 0) return true; // keep the original finger ownership
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
- if (is_quick_slot_button(btn.id) && PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(btn.id - 2))
+ if (is_quick_slot_button(btn.id) && PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(quick_slot_index(btn.id)))
return true;
#endif
btn.pressed = true;
@@ -145,7 +153,8 @@ public:
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
// Only hover here (the tooltip shows while the finger rests); what the finger does next picks
// the gesture: lift = click, slide = press + drag, hold = right click, second tap = double click.
- if (PythonBoot::IsPointInsideActiveUI(int(px), int(py))) {
+ if (ui_hit) {
+ if (ui_touch_finger_id_ >= 0) return true; // one UI gesture owns its pointer
ui_touch_finger_id_ = finger_id;
ui_gesture_ = UiGesture::Pending;
ui_start_x_ = px;
@@ -214,6 +223,8 @@ public:
// or scroll bar captures), then follow the finger.
ui_gesture_ = UiGesture::Dragging;
last_tap_time_ = -1.0;
+ PythonBoot::SetInventoryDrag(true);
+ PythonBoot::UIMouseMove(int(ui_start_x_), int(ui_start_y_));
PythonBoot::UIMouseButton(1, true, int(ui_start_x_), int(ui_start_y_));
}
PythonBoot::UIMouseMove(int(px), int(py));
@@ -297,12 +308,16 @@ public:
if (ui_gesture_ == UiGesture::Dragging) {
// Dropped on a skill button: that quick slot takes the dragged icon.
const int btn_idx = gameplay_controls_visible() ? find_button(px, py) : -1;
- if (btn_idx >= 0 && is_quick_slot_button(buttons_[btn_idx].id) && PythonBoot::UIIsAttaching()) {
- PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(buttons_[btn_idx].id - 2);
+ if (PythonBoot::FinishInventoryDrag(int(px), int(py))) {
+ PythonBoot::UIMouseButton(1, false, int(ui_start_x_), int(ui_start_y_));
+ } else if (btn_idx >= 0 && is_quick_slot_button(buttons_[btn_idx].id) &&
+ !PythonBoot::IsPointInsideActiveUI(int(px), int(py)) && PythonBoot::UIIsAttaching()) {
+ PythonBoot::TryAssignAttachedObjectToLocalQuickSlot(quick_slot_index(buttons_[btn_idx].id));
PythonBoot::UIMouseButton(1, false, int(ui_start_x_), int(ui_start_y_));
} else {
PythonBoot::UIMouseButton(1, false, int(px), int(py));
}
+ PythonBoot::SetInventoryDrag(false);
} else if (ui_gesture_ == UiGesture::Pending) {
const double now = get_time_sec();
const int x = int(ui_start_x_), y = int(ui_start_y_);
@@ -312,13 +327,17 @@ public:
// mouse user would have seen and a finger hides, so put it back first.
if (last_tap_attached_ && PythonBoot::UIIsAttaching())
PythonBoot::UIDeattachObject();
+ PythonBoot::SetInventoryDoubleTap(true);
PythonBoot::UIMouseDoubleClick(x, y);
PythonBoot::UIMouseButton(1, false, x, y);
+ PythonBoot::SetInventoryDoubleTap(false);
last_tap_time_ = -1.0;
} else {
const bool was_attaching = PythonBoot::UIIsAttaching();
+ PythonBoot::SetInventoryTap(true);
PythonBoot::UIMouseButton(1, true, x, y);
PythonBoot::UIMouseButton(1, false, x, y);
+ PythonBoot::SetInventoryTap(false);
last_tap_time_ = now;
last_tap_x_ = ui_start_x_;
last_tap_y_ = ui_start_y_;
@@ -400,11 +419,9 @@ public:
// Right-side attack and quick-slot buttons.
for (const auto& btn : buttons_) {
- const uint32_t col = btn.pressed ? btn.color_pressed : btn.color_idle;
- draw_filled_disc(commands, btn.x, btn.y, btn.radius, col);
- draw_circle(commands, btn.x, btn.y, btn.radius * 0.85f, btn.pressed ? 0xFFFFFFFF : 0x70FFFFFF, 16);
+ draw_control_disc(commands, btn.x, btn.y, btn.radius, btn.pressed);
- const uint32_t icon_col = btn.pressed ? 0xFFFFFFFF : 0xDDFFFFFF;
+ const uint32_t icon_col = 0xFFFFFFFF;
const float r = btn.radius;
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
@@ -416,13 +433,13 @@ public:
std::string skill_icon;
float uv[4];
int count = 0;
- if (PythonBoot::LocalQuickSlotIcon(btn.id - 2, &skill_icon, uv, &count)) {
+ if (PythonBoot::LocalQuickSlotIcon(quick_slot_index(btn.id), &skill_icon, uv, &count)) {
UIRenderCommand icon{};
icon.kind = UIRenderCommand::Image;
- icon.x1 = btn.x - r * 0.68f;
- icon.y1 = btn.y - r * 0.68f;
- icon.x2 = btn.x + r * 0.68f;
- icon.y2 = btn.y + r * 0.68f;
+ icon.x1 = btn.x - r * 0.64f;
+ icon.y1 = btn.y - r * 0.64f;
+ icon.x2 = btn.x + r * 0.64f;
+ icon.y2 = btn.y + r * 0.64f;
icon.argb = icon_col;
icon.text = skill_icon;
icon.su = uv[0];
@@ -430,6 +447,17 @@ public:
icon.eu = uv[2];
icon.ev = uv[3];
commands.push_back(std::move(icon));
+ const auto state = PythonBoot::LocalQuickSlotState(quick_slot_index(btn.id));
+ if (!state.learned || state.remaining > 0.0f) {
+ draw_filled_disc(commands, btn.x, btn.y, r * 0.82f, 0xA0000000);
+ if (state.remaining > 0.0f) {
+ const std::string seconds = std::to_string(int(std::ceil(state.remaining)));
+ const float h = r * 0.55f;
+ draw_segment_text(commands, seconds, btn.x - segment_text_width(seconds, h) / 2,
+ btn.y - h / 2, h);
+ }
+ }
+ if (state.active) draw_circle(commands, btn.x, btn.y, r * 0.92f, 0xFF80FF90, 24);
if (count > 0) {
const std::string digits = std::to_string(std::min(count, 9999));
const float h = r * 0.26f;
@@ -441,6 +469,14 @@ public:
}
#endif
+ if (btn.id >= 9) {
+ const std::string label = std::to_string(quick_slot_index(btn.id) + 1);
+ const float h = r * 0.65f;
+ draw_segment_text(commands, label, btn.x - segment_text_width(label, h) / 2,
+ btn.y - h / 2, h, 0xFFD8C49A);
+ continue;
+ }
+
switch (btn.id) {
case 1: { // ATK: crossed swords
const float s = r * 0.35f;
@@ -451,67 +487,60 @@ public:
draw_line(commands, btn.x + s*0.4f - g, btn.y - s*0.4f - g, btn.x + s*0.4f + g, btn.y - s*0.4f + g, icon_col);
break;
}
- case 2: { // S1: I
- const float h = r * 0.35f;
- draw_line(commands, btn.x, btn.y - h, btn.x, btn.y + h, icon_col);
- draw_line(commands, btn.x - 4.0f, btn.y - h, btn.x + 4.0f, btn.y - h, icon_col);
- draw_line(commands, btn.x - 4.0f, btn.y + h, btn.x + 4.0f, btn.y + h, icon_col);
- break;
- }
- case 3: { // S2: II
- const float h = r * 0.35f;
- draw_line(commands, btn.x - 4.0f, btn.y - h, btn.x - 4.0f, btn.y + h, icon_col);
- draw_line(commands, btn.x + 4.0f, btn.y - h, btn.x + 4.0f, btn.y + h, icon_col);
- break;
- }
- case 4: { // S3: III
- const float h = r * 0.35f;
- draw_line(commands, btn.x - 6.0f, btn.y - h, btn.x - 6.0f, btn.y + h, icon_col);
- draw_line(commands, btn.x, btn.y - h, btn.x, btn.y + h, icon_col);
- draw_line(commands, btn.x + 6.0f, btn.y - h, btn.x + 6.0f, btn.y + h, icon_col);
- break;
- }
- case 5: { // S4: IV
- const float h = r * 0.35f;
- draw_line(commands, btn.x - 7.0f, btn.y - h, btn.x - 7.0f, btn.y + h, icon_col);
- draw_line(commands, btn.x, btn.y - h, btn.x + 7.0f, btn.y + h, icon_col);
- draw_line(commands, btn.x + 14.0f, btn.y - h, btn.x + 7.0f, btn.y + h, icon_col);
- break;
- }
- case 6: { // S5: V
- const float h = r * 0.35f;
- draw_line(commands, btn.x - 8.0f, btn.y - h, btn.x, btn.y + h, icon_col);
- draw_line(commands, btn.x + 8.0f, btn.y - h, btn.x, btn.y + h, icon_col);
- break;
- }
- case 7: { // S6: VI
- const float h = r * 0.35f;
- draw_line(commands, btn.x - 11.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col);
- draw_line(commands, btn.x + 5.0f, btn.y - h, btn.x - 3.0f, btn.y + h, icon_col);
- draw_line(commands, btn.x + 11.0f, btn.y - h, btn.x + 11.0f, btn.y + h, icon_col);
+ case 2: case 3: case 4: case 5: case 6: case 7: {
+ // Unassigned slots use the reference's small bronze plus.
+ const float arm = r * 0.30f;
+ const float half_width = std::max(1.5f, r * 0.085f) * 0.5f;
+ const uint32_t bronze = btn.pressed ? 0xFF997847 : 0xE6997847;
+ draw_rect_bar(commands, btn.x - arm, btn.y - half_width,
+ btn.x + arm, btn.y + half_width, bronze);
+ draw_rect_bar(commands, btn.x - half_width, btn.y - arm,
+ btn.x + half_width, btn.y + arm, bronze);
break;
}
}
}
- // Left-side movement joystick.
- draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_, 0x8080C0FF, 24);
- draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_ * 0.45f, 0x4080C0FF, 16);
- draw_line(commands, joystick_base_x_ - joystick_radius_, joystick_base_y_,
- joystick_base_x_ - joystick_radius_ + 8.0f, joystick_base_y_, 0x90FFFFFF);
- draw_line(commands, joystick_base_x_ + joystick_radius_ - 8.0f, joystick_base_y_,
- joystick_base_x_ + joystick_radius_, joystick_base_y_, 0x90FFFFFF);
- draw_line(commands, joystick_base_x_, joystick_base_y_ - joystick_radius_,
- joystick_base_x_, joystick_base_y_ - joystick_radius_ + 8.0f, 0x90FFFFFF);
- draw_line(commands, joystick_base_x_, joystick_base_y_ + joystick_radius_ - 8.0f,
- joystick_base_x_, joystick_base_y_ + joystick_radius_, 0x90FFFFFF);
-
- if (joystick_active_) {
- draw_line(commands, joystick_base_x_, joystick_base_y_, joystick_knob_x_, joystick_knob_y_, 0xB000FFFF);
+ // D2R-style floating stick: faint bronze base, star thumb and movement arc.
+ draw_circle(commands, joystick_base_x_, joystick_base_y_, joystick_radius_,
+ joystick_active_ ? 0x52997847 : 0x1F997847, 64);
+ const uint32_t thumb_fill = joystick_active_ ? 0xCC211B10 : 0x2E211B10;
+ const uint32_t thumb_edge = joystick_active_ ? 0xCCB89545 : 0x2EB89545;
+ draw_filled_disc(commands, joystick_knob_x_, joystick_knob_y_, joystick_knob_radius_, thumb_fill);
+ draw_circle(commands, joystick_knob_x_, joystick_knob_y_, joystick_knob_radius_, thumb_edge, 48);
+ draw_circle(commands, joystick_knob_x_, joystick_knob_y_, joystick_knob_radius_ * 0.91f,
+ joystick_active_ ? 0x995B4725 : 0x225B4725, 48);
+ const float star_radius = joystick_knob_radius_ * 0.55f;
+ const float inner = star_radius * 0.18f;
+ const int rows = int(std::ceil(star_radius * 2.0f));
+ for (int i = 0; i < rows; ++i) {
+ const float y = -star_radius + (i + 0.5f) * (star_radius * 2.0f / rows);
+ const float d = std::abs(y);
+ const float half = d < inner ? star_radius - (star_radius - inner) * d / inner
+ : inner * (star_radius - d) / (star_radius - inner);
+ draw_rect_bar(commands, joystick_knob_x_ - half,
+ joystick_knob_y_ - star_radius + i * (star_radius * 2.0f / rows),
+ joystick_knob_x_ + half,
+ joystick_knob_y_ - star_radius + (i + 1) * (star_radius * 2.0f / rows),
+ joystick_active_ ? 0xCCD4AE5F : 0x2ED4AE5F);
+ }
+ const float dx = joystick_knob_x_ - joystick_base_x_;
+ const float dy = joystick_knob_y_ - joystick_base_y_;
+ if (joystick_active_ && dx * dx + dy * dy > 0.01f) {
+ const float angle = std::atan2(dy, dx);
+ const float scale = joystick_radius_ / 65.0f;
+ for (int i = 0; i < 20; ++i) {
+ const float a = angle - 3.14159265f / 6.0f + i * 3.14159265f / 60.0f;
+ const float b = a + 3.14159265f / 60.0f;
+ for (int stroke = 0; stroke < 2; ++stroke) {
+ const float radius = joystick_radius_ + (3.0f + stroke) * scale;
+ draw_line(commands, joystick_base_x_ + std::cos(a) * radius,
+ joystick_base_y_ + std::sin(a) * radius,
+ joystick_base_x_ + std::cos(b) * radius,
+ joystick_base_y_ + std::sin(b) * radius, 0xB3997847);
+ }
+ }
}
- const uint32_t knob_color = joystick_active_ ? 0xB040A0FF : 0x6040A0FF;
- draw_filled_disc(commands, joystick_knob_x_, joystick_knob_y_, joystick_knob_radius_, knob_color);
- draw_circle(commands, joystick_knob_x_, joystick_knob_y_, joystick_knob_radius_ * 0.5f, 0x80FFFFFF, 12);
}
// Desktop mouse testing simulation
@@ -617,7 +646,8 @@ private:
}
static constexpr int kAutoHuntButton = 8;
- static bool is_quick_slot_button(int id) { return id >= 2 && id <= 7; }
+ static bool is_quick_slot_button(int id) { return (id >= 2 && id <= 7) || id == 9 || id == 10; }
+ static int quick_slot_index(int id) { return id >= 9 ? id - 3 : id - 2; }
// A circular arrow; green while the hunt runs.
static void draw_auto_hunt_button(std::vector& commands, const ButtonDef& btn) {
@@ -654,7 +684,7 @@ private:
void init_buttons() {
buttons_.clear();
// Lower-right combat controls. All menus and status UI remain the original 40250 UI.
- buttons_.push_back({1, 0x39, 0, 0, 42.0f, "ATK", 0x80D48820, 0xD0FFB040});
+ buttons_.push_back({1, 0x39, 0, 0, 42.0f, "ATK", 0xC04C2818, 0xE0A77832});
buttons_.push_back({2, 0x02, 0, 0, 26.0f, "S1", 0x803060C0, 0xD05080FF});
buttons_.push_back({3, 0x03, 0, 0, 26.0f, "S2", 0x80903090, 0xD0D050D0});
buttons_.push_back({4, 0x04, 0, 0, 26.0f, "S3", 0x80309060, 0xD050D080});
@@ -662,66 +692,38 @@ private:
buttons_.push_back({6, 0x06, 0, 0, 26.0f, "S5", 0x80603090, 0xD08050D0});
buttons_.push_back({7, 0x07, 0, 0, 26.0f, "S6", 0x80308090, 0xD050C0D0});
buttons_.push_back({kAutoHuntButton, 0, 0, 0, 24.0f, "AUTO", 0x80707070, 0xD0FFFFFF});
+ buttons_.push_back({9, 0x08, 0, 0, 28.0f, "7", 0xB032211B, 0xE0664B2D});
+ buttons_.push_back({10, 0x09, 0, 0, 28.0f, "8", 0xB032211B, 0xE0664B2D});
+ for (auto& btn : buttons_) {
+ if (is_quick_slot_button(btn.id)) {
+ btn.color_idle = 0xA032211B;
+ btn.color_pressed = 0xE08C6836;
+ }
+ }
}
void layout_buttons() {
- const float W = float(screen_w_) - safe_inset_;
- const float H = float(screen_h_);
+ const auto layout = mt_host::make_touch_layout(float(screen_w_), float(screen_h_),
+ safe_inset_, minimum_touch_target_);
+ joystick_radius_ = 65.0f * layout.scale;
+ joystick_knob_radius_ = 23.0f * layout.scale;
for (auto& btn : buttons_) {
- switch (btn.id) {
- case 1:
- btn.x = W - 75.0f;
- btn.y = H - 85.0f;
- btn.radius = std::min(46.0f, H * 0.12f);
- break;
- case 2:
- btn.x = W - 360.0f;
- btn.y = H - 75.0f;
- btn.radius = std::min(28.0f, H * 0.08f);
- break;
- case 3:
- btn.x = W - 295.0f;
- btn.y = H - 75.0f;
- btn.radius = std::min(28.0f, H * 0.08f);
- break;
- case 4:
- btn.x = W - 230.0f;
- btn.y = H - 75.0f;
- btn.radius = std::min(28.0f, H * 0.08f);
- break;
- case 5:
- btn.x = W - 165.0f;
- btn.y = H - 85.0f;
- btn.radius = std::min(28.0f, H * 0.08f);
- break;
- case 6:
- btn.x = W - 125.0f;
- btn.y = H - 155.0f;
- btn.radius = std::min(28.0f, H * 0.08f);
- break;
- case 7:
- btn.x = W - 75.0f;
- btn.y = H - 225.0f;
- btn.radius = std::min(28.0f, H * 0.08f);
- break;
- case kAutoHuntButton:
- btn.x = W - 75.0f;
- btn.y = H - 295.0f;
- btn.radius = std::min(24.0f, H * 0.07f);
- break;
- }
+ const auto& circle = layout.buttons[btn.id - 1];
+ btn.x = circle.x;
+ btn.y = circle.y;
+ btn.radius = circle.radius;
}
}
int find_button(float x, float y) {
+ int nearest = -1;
+ float best = 1.25f;
for (size_t i = 0; i < buttons_.size(); ++i) {
const auto& btn = buttons_[i];
- const float dist = std::hypot(x - btn.x, y - btn.y);
- if (dist <= btn.radius * 1.25f) {
- return int(i);
- }
+ const float distance = std::hypot(x - btn.x, y - btn.y) / btn.radius;
+ if (distance <= best) { best = distance; nearest = int(i); }
}
- return -1;
+ return nearest;
}
void trigger_button(int dik, bool pressed) {
@@ -729,7 +731,7 @@ private:
if (dik == 0x39) {
PythonBoot::SetAttackKey(pressed);
}
- if (dik >= 0x02 && dik <= 0x07) {
+ if (dik >= 0x02 && dik <= 0x09) {
if (pressed)
PythonBoot::UseLocalQuickSlot(dik - 0x02);
return;
@@ -810,14 +812,26 @@ private:
static void draw_filled_disc(std::vector& commands,
float cx, float cy, float radius, uint32_t argb) {
- // Base rectangular fill
- draw_rect_bar(commands, cx - radius * 0.65f, cy - radius * 0.65f, cx + radius * 0.65f, cy + radius * 0.65f, (argb & 0x00FFFFFF) | 0x55000000);
- // Cross fills for roundness
- draw_rect_bar(commands, cx - radius * 0.85f, cy - radius * 0.35f, cx + radius * 0.85f, cy + radius * 0.35f, (argb & 0x00FFFFFF) | 0x55000000);
- draw_rect_bar(commands, cx - radius * 0.35f, cy - radius * 0.85f, cx + radius * 0.35f, cy + radius * 0.85f, (argb & 0x00FFFFFF) | 0x55000000);
- // Border rings
- draw_circle(commands, cx, cy, radius, argb, 20);
- draw_circle(commands, cx, cy, radius - 1.0f, (argb & 0x00FFFFFF) | 0x40000000, 20);
+ // Non-overlapping scanlines preserve the requested alpha across the disc.
+ const int rows = std::max(1, int(std::ceil(radius * 2.0f)));
+ const float height = radius * 2.0f / rows;
+ for (int i = 0; i < rows; ++i) {
+ const float y = -radius + (i + 0.5f) * height;
+ const float half = std::sqrt(std::max(0.0f, radius * radius - y * y));
+ draw_rect_bar(commands, cx - half, cy - radius + i * height,
+ cx + half, cy - radius + (i + 1) * height, argb);
+ }
+ }
+
+ static void draw_control_disc(std::vector& commands,
+ float cx, float cy, float radius, bool held) {
+ draw_filled_disc(commands, cx, cy, radius, held ? 0xEB16110C : 0x7A16110C);
+ const float width = std::max(1.5f, radius * 0.075f);
+ for (int i = 0; i < int(std::ceil(width)); ++i)
+ draw_circle(commands, cx, cy, radius - 0.5f - i,
+ held ? 0xFF997847 : 0x8A997847, 64);
+ draw_circle(commands, cx, cy, radius - width * 1.3f,
+ held ? 0xE61F170D : 0x8A1F170D, 64);
}
static double get_time_sec() {
@@ -855,8 +869,9 @@ private:
float joystick_base_y_ = 580.0f;
float joystick_knob_x_ = 140.0f;
float joystick_knob_y_ = 580.0f;
+ float minimum_touch_target_ = 48.0f;
float joystick_radius_ = 65.0f;
- float joystick_knob_radius_ = 28.0f;
+ float joystick_knob_radius_ = 23.0f;
float move_angle_ = 0.0f;
// Zones are fractions of the screen; distances are logical UI pixels.
diff --git a/src/host/touch_layout.h b/src/host/touch_layout.h
new file mode 100644
index 00000000..84ce3729
--- /dev/null
+++ b/src/host/touch_layout.h
@@ -0,0 +1,41 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace mt_host {
+
+struct TouchCircle { float x, y, radius; };
+
+struct TouchLayout {
+ // Attack, six skills, auto hunt, two bottom item quick slots.
+ std::array buttons;
+ float scale;
+};
+
+inline TouchLayout make_touch_layout(float width, float height, float safe_inset, float minimum_target) {
+ TouchLayout layout{};
+ layout.scale = std::max(minimum_target / 52.0f,
+ std::clamp(std::min(height / 600.0f, (width - 2 * safe_inset) / 800.0f), 0.65f, 1.35f));
+ const float s = layout.scale;
+ const float ax = width - safe_inset - 78 * s;
+ // Leave the original status/menu strip clear below the lowest skill.
+ const float ay = height - 136 * s;
+ layout.buttons[0] = {ax, ay, 44 * s};
+ // PORT: D2R Mobile's main-button + single skill arc arrangement, extended to six slots.
+ // 24-degree spacing at radius 176 keeps even the expanded (1.25x) hit circles separate.
+ for (int slot = 0; slot < 6; ++slot) {
+ const float angle = (160.0f + 24.0f * slot) * 3.14159265f / 180.0f;
+ layout.buttons[slot + 1] = {ax + std::cos(angle) * 176 * s,
+ ay + std::sin(angle) * 176 * s, 28 * s};
+ }
+ // Keep the settings/toggle action outside the arc, away from repeated attack taps.
+ layout.buttons[7] = {ax - 246 * s, ay - 84 * s, 26 * s};
+ const float item_center = std::min(width / 2, layout.buttons[1].x - 114 * s);
+ layout.buttons[8] = {item_center - 36 * s, height - 38 * s, 28 * s};
+ layout.buttons[9] = {item_center + 36 * s, height - 38 * s, 28 * s};
+ return layout;
+}
+
+} // namespace mt_host
diff --git a/src/host/vk_frame.cpp b/src/host/vk_frame.cpp
index e7b64fe2..57e8e64e 100644
--- a/src/host/vk_frame.cpp
+++ b/src/host/vk_frame.cpp
@@ -315,11 +315,23 @@ void VulkanWindow::prepare_ui(std::uint32_t ui_width, std::uint32_t ui_height, c
const uint32_t target_ui_h = ui_height ? ui_height : 640;
update_fps_counter();
if (touch_controller.is_enabled() || show_fps_) {
- std::vector combined_ui = ui_commands;
+ std::vector combined_ui;
if (touch_controller.is_enabled()) {
+ // Convert a 48dp target to this frame's UI coordinates, independently of 3D scale.
+ int pixel_h = 0;
+ SDL_GetWindowSizeInPixels(window_, nullptr, &pixel_h);
+ const float density = SDL_GetWindowDisplayScale(window_);
+ const float target = pixel_h > 0 && density > 0 ?
+ 48.0f * density * float(target_ui_h) / float(pixel_h) : 48.0f;
+ touch_controller.set_minimum_touch_target(target);
+#ifdef MT_NATIVE_HAS_LIVE_CLIENT
+ PythonBoot::SetInventoryTouchTarget(target);
+#endif
touch_controller.update_screen_size(int(target_ui_w), int(target_ui_h), ui_safe_inset());
if (!SkipDiagnosticRender("touch")) touch_controller.append_ui_commands(combined_ui);
}
+ // Inventory, shop and confirmation windows cover the combat overlay, as their hit tests do.
+ combined_ui.insert(combined_ui.end(), ui_commands.begin(), ui_commands.end());
if (show_fps_ && fps_ >= 0) {
// Top centre, clear of the original affect bar and the mini-map.
const float inset = float(std::min(ui_safe_inset(), int(target_ui_w) / 8));
diff --git a/src/platform/CMakeLists.txt b/src/platform/CMakeLists.txt
index 3deee0d5..f94368f7 100644
--- a/src/platform/CMakeLists.txt
+++ b/src/platform/CMakeLists.txt
@@ -10,7 +10,7 @@ if(NOT TARGET mtpython)
list(FILTER MT_PLATFORM_SOURCES EXCLUDE REGEX "/ScriptLib/")
endif()
add_library(port_platform STATIC ${MT_PLATFORM_SOURCES})
-target_link_libraries(port_platform PUBLIC port_logic PRIVATE mt3p::freetype mt::libgr2)
+target_link_libraries(port_platform PUBLIC port_logic PRIVATE mt3p::freetype mt::libgr2 speedtree)
foreach(src IN LISTS MT_PLATFORM_SOURCES)
mt_port_apply_defines(${src})
endforeach()
diff --git a/src/platform/ScriptLib/BiologScript.inc b/src/platform/ScriptLib/BiologScript.inc
new file mode 100644
index 00000000..c97656f9
--- /dev/null
+++ b/src/platform/ScriptLib/BiologScript.inc
@@ -0,0 +1,457 @@
+// PORT: AVA2 biology (bio.*), with native touch-sized controls and original command vocabulary.
+static const char c_szBiologScript[] = R"PY(# -*- coding: utf-8 -*-
+import app
+import chat
+import game
+import grp
+import item
+import net
+import player
+import ui
+import uiToolTip
+import wndMgr
+import weakref
+import os
+import sys
+L = player.AutoHuntLocalize
+g_window = None
+g_entry = None
+CATALOG = {1: {'index': 1, 'level': 30, 'material': 30006, 'count': 10, 'cooldown': 600, 'chance': 60, 'stone': 30220, 'bonuses': [{'point': 'POINT_DEF_GRADE_BONUS', 'label': '防御力', 'value': 30}], 'choose': False, 'enhancer': 71035, 'skip': 30250}, 2: {'index': 2, 'level': 40, 'material': 30047, 'count': 15, 'cooldown': 600, 'chance': 60, 'stone': 30221, 'bonuses': [{'point': 'POINT_ATT_GRADE_BONUS', 'label': '攻击力', 'value': 40}], 'choose': False, 'enhancer': 71035, 'skip': 30250}, 3: {'index': 3, 'level': 50, 'material': 30015, 'count': 15, 'cooldown': 900, 'chance': 60, 'stone': 30222, 'bonuses': [{'point': 'POINT_ATTBONUS_MONSTER', 'label': '对怪物伤害 %', 'value': 5}], 'choose': False, 'enhancer': 71035, 'skip': 30250}, 4: {'index': 4, 'level': 60, 'material': 30050, 'count': 20, 'cooldown': 900, 'chance': 60, 'stone': 30223, 'bonuses': [{'point': 'POINT_ATTBONUS_STONE', 'label': '对陨石伤害 %', 'value': 4}, {'point': 'POINT_ATTBONUS_BOSS', 'label': '对 Boss 伤害 %', 'value': 4}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 5: {'index': 5, 'level': 70, 'material': 300004, 'count': 25, 'cooldown': 1800, 'chance': 60, 'stone': 30224, 'bonuses': [{'point': 'POINT_ATTBONUS_BOSS', 'label': '对 Boss 伤害 %', 'value': 6}, {'point': 'POINT_ATTBONUS_STONE', 'label': '对陨石伤害 %', 'value': 6}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 6: {'index': 6, 'level': 80, 'material': 300005, 'count': 30, 'cooldown': 1800, 'chance': 60, 'stone': 30225, 'bonuses': [{'point': 'POINT_MAX_HP', 'label': '生命值', 'value': 1000}, {'point': 'POINT_CASTING_SPEED', 'label': '施法速度', 'value': 10}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 7: {'index': 7, 'level': 85, 'material': 300006, 'count': 40, 'cooldown': 2700, 'chance': 60, 'stone': 30226, 'bonuses': [{'point': 'POINT_RESIST_WARRIOR', 'label': '战士抗性 %', 'value': 10}, {'point': 'POINT_RESIST_ASSASSIN', 'label': '忍者抗性 %', 'value': 10}, {'point': 'POINT_RESIST_SURA', 'label': '修罗抗性 %', 'value': 10}, {'point': 'POINT_RESIST_SHAMAN', 'label': '萨满抗性 %', 'value': 10}], 'choose': False, 'enhancer': 71035, 'skip': 30250}, 8: {'index': 8, 'level': 90, 'material': 300007, 'count': 50, 'cooldown': 3600, 'chance': 60, 'stone': 30227, 'bonuses': [{'point': 'POINT_ATTBONUS_WARRIOR', 'label': '对战士伤害 %', 'value': 10}, {'point': 'POINT_ATTBONUS_ASSASSIN', 'label': '对忍者伤害 %', 'value': 10}, {'point': 'POINT_ATTBONUS_SURA', 'label': '对修罗伤害 %', 'value': 10}, {'point': 'POINT_ATTBONUS_SHAMAN', 'label': '对萨满伤害 %', 'value': 10}], 'choose': False, 'enhancer': 71035, 'skip': 30250}, 9: {'index': 9, 'level': 92, 'material': 300008, 'count': 10, 'cooldown': 7200, 'chance': 60, 'stone': 30228, 'bonuses': [{'point': 'POINT_MAX_HP', 'label': '生命值', 'value': 1200}, {'point': 'POINT_DEF_GRADE_BONUS', 'label': '防御力', 'value': 120}, {'point': 'POINT_ATT_GRADE_BONUS', 'label': '攻击力', 'value': 50}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 10: {'index': 10, 'level': 94, 'material': 300009, 'count': 20, 'cooldown': 7200, 'chance': 60, 'stone': 30229, 'bonuses': [{'point': 'POINT_MAX_HP', 'label': '生命值', 'value': 1500}, {'point': 'POINT_DEF_GRADE_BONUS', 'label': '防御力', 'value': 140}, {'point': 'POINT_ATT_GRADE_BONUS', 'label': '攻击力', 'value': 60}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 11: {'index': 11, 'level': 98, 'material': 300010, 'count': 35, 'cooldown': 10800, 'chance': 60, 'stone': 30230, 'bonuses': [{'point': 'POINT_ATTBONUS_MONSTER', 'label': '对怪物伤害 %', 'value': 10}, {'point': 'POINT_RESIST_HUMAN', 'label': '半人抗性 %', 'value': 10}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 12: {'index': 12, 'level': 100, 'material': 300011, 'count': 35, 'cooldown': 10800, 'chance': 60, 'stone': 30231, 'bonuses': [{'point': 'POINT_ATTBONUS_STONE', 'label': '对陨石伤害 %', 'value': 10}, {'point': 'POINT_MELEE_MAGIC_ATT_BONUS_PER', 'label': '近战及魔法攻击 %', 'value': 10}], 'choose': True, 'enhancer': 71035, 'skip': 30250}, 13: {'index': 13, 'level': 105, 'material': 300012, 'count': 35, 'cooldown': 10800, 'chance': 60, 'stone': 30232, 'bonuses': [{'point': 'POINT_ATTBONUS_BOSS', 'label': '对 Boss 伤害 %', 'value': 10}, {'point': 'POINT_ATTBONUS_HUMAN', 'label': '对半人伤害 %', 'value': 10}], 'choose': True, 'enhancer': 71035, 'skip': 30250}}
+SHOP = [{'vnum': 30220, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 5000000}, {'vnum': 30221, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 7000000}, {'vnum': 30222, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 10000000}, {'vnum': 30223, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 13000000}, {'vnum': 30224, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 20000000}, {'vnum': 30225, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 25000000}, {'vnum': 30226, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 50000000}, {'vnum': 30227, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 100000000}, {'vnum': 30228, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 200000000}, {'vnum': 30229, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 200000000}, {'vnum': 30230, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 500000000}, {'vnum': 30231, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 500000000}, {'vnum': 30232, 'count': 1, 'cost': 0, 'amount': 0, 'gold': 500000000}, {'vnum': 300004, 'count': 1, 'cost': 30091, 'amount': 5, 'gold': 0}, {'vnum': 300005, 'count': 1, 'cost': 30197, 'amount': 3, 'gold': 0}, {'vnum': 300006, 'count': 1, 'cost': 30198, 'amount': 2, 'gold': 0}, {'vnum': 300007, 'count': 1, 'cost': 30196, 'amount': 2, 'gold': 0}, {'vnum': 300008, 'count': 1, 'cost': 70031, 'amount': 3, 'gold': 0}, {'vnum': 300009, 'count': 1, 'cost': 70031, 'amount': 5, 'gold': 0}, {'vnum': 300010, 'count': 1, 'cost': 30520, 'amount': 7, 'gold': 0}, {'vnum': 300011, 'count': 1, 'cost': 30521, 'amount': 7, 'gold': 0}, {'vnum': 300012, 'count': 1, 'cost': 30518, 'amount': 7, 'gold': 0}]
+
+def BonusText(bonus):
+ return bonus['label'].replace(" %", "")+" +%d"%bonus['value']+("%" if bonus['label'].endswith("%") else "")
+
+
+def Text(parent, x, y, text="", color=0xffe8dec8):
+ line = ui.TextLine()
+ line.SetParent(parent)
+ line.AddFlag("not_pick")
+ line.SetPosition(x, y)
+ line.SetText(L(text))
+ line.SetPackedFontColor(color)
+ line.Show()
+ return line
+
+
+class Action(ui.Window):
+ def __init__(self, parent, x, y, width, text, event):
+ ui.Window.__init__(self)
+ if parent:
+ self.SetParent(parent)
+ self.SetPosition(x, y)
+ self.SetSize(width, 48)
+ self.event = event
+ self.enabled = True
+ self.down = False
+ self.label = Text(self, width/2, 24, text)
+ self.label.SetHorizontalAlignCenter()
+ self.label.SetVerticalAlignCenter()
+ self.Show()
+
+ def SetText(self, text):
+ self.label.SetText(L(text))
+
+ def SetEnabled(self, enabled):
+ self.enabled = bool(enabled)
+ self.label.SetPackedFontColor(0xffefdbac if enabled else 0xff817c70)
+
+ def OnRender(self):
+ x, y = self.GetGlobalPosition()
+ grp.SetColor(0xdd684f2f if self.down else 0xdd2d261d)
+ grp.RenderBar(x, y, self.GetWidth(), self.GetHeight())
+ grp.SetColor(0xffb99a66 if self.enabled else 0xff57534b)
+ grp.RenderBox(x, y, self.GetWidth(), self.GetHeight())
+
+ def OnMouseLeftButtonDown(self):
+ self.down = self.enabled
+ return True
+
+ def OnMouseLeftButtonUp(self):
+ invoke = self.down and self.enabled and self.IsIn()
+ self.down = False
+ if invoke:
+ self.event()
+ return True
+
+ def OnMouseOverOut(self):
+ self.down = False
+
+
+class BiologWindow(ui.Window):
+ def __init__(self):
+ ui.Window.__init__(self)
+ self.SetWindowName("BiologWindow")
+ self.AddFlag("float")
+ self.AddFlag("movable")
+ self.SetSize(540, 400)
+ self.heading = Text(self, 22, 18, "生物学家研究", 0xffffd68b)
+ self.closeButton = Action(self, 470, 6, 48, "关闭", self.Hide)
+ self.title = Text(self, 22, 48)
+ self.phase = Text(self, 22, 68)
+ self.material = Text(self, 104, 92)
+ self.progress = Text(self, 104, 114)
+ self.owned = Text(self, 104, 136)
+ self.cooldown = Text(self, 22, 160)
+ self.reward = [Text(self, 22+(i%2)*260, 188+(i/2)*22) for i in range(4)]
+ self.message = Text(self, 22, 378, "正在同步研究进度", 0xffb3aa98)
+ self.icon = ui.ExpandedImageBox()
+ self.icon.SetParent(self)
+ self.icon.SetPosition(26, 92)
+ self.icon.AddFlag("not_pick")
+ self.tooltip = uiToolTip.ItemToolTip()
+ self.tooltip.Hide()
+ self.enhance = Action(self, 22, 288, 158, "精华:开启", lambda:self.Option(0))
+ self.skip = Action(self, 190, 288, 158, "跳过冷却:开启", lambda:self.Option(1))
+ self.reminder = Action(self, 358, 288, 160, "到期提醒:开启", lambda:self.Option(2))
+ self.submit = Action(self, 22, 344, 158, "提交材料", self.Act)
+ self.shopButton = Action(self, 190, 344, 158, "研究商店", self.Shop)
+ self.resetButton = Action(self, 358, 344, 160, "重选奖励", self.Reset)
+ self.choices = [Action(self, 22+(i%2)*260, 188+(i/2)*54, 238, "", lambda i=i:self.Gift(i)) for i in range(4)]
+ self.prev = Action(self, 22, 288, 158, "上一项", lambda:self.Browse(-1))
+ self.next = Action(self, 358, 288, 160, "下一项", lambda:self.Browse(1))
+ self.options = [True, True, True] # AVA2 defaults: enhancer, time skip, reminder.
+ self.index = 0
+ self.count = 0
+ self.deadline = 0
+ self.serverTime = 0
+ self.receivedAt = 0
+ self.pendingUntil = 0
+ self.nextQuery = app.GetTime()+1
+ self.queryStarted = 0
+ self.mode = "research"
+ self.bonuses = [0]*13
+ self.browse = 0
+ self.hasState = False
+ self.iconVnum = 0
+ self.iconHover = False
+ self.lastRemaining = 0
+ self.dimensions = None
+ self.LoadOptions()
+ self.Hide()
+ self.Refresh()
+
+ def OnRender(self):
+ x,y = self.GetGlobalPosition()
+ grp.SetColor(0xf51b1916)
+ grp.RenderBar(x,y,self.GetWidth(),self.GetHeight())
+ grp.SetColor(0xff9b7b48)
+ grp.RenderBox(x,y,self.GetWidth(),self.GetHeight())
+
+ def PreferencePath(self):
+ # Keep preferences outside a signed macOS app; Android falls back to its writable client dir.
+ base = os.environ.get("HOME", ".")
+ if sys.platform.startswith("linux"):
+ base = "."
+ folder = os.path.join(base, ".metin2", "biolog")
+ name = player.GetName()
+ return folder, os.path.join(folder, name.encode("hex")+".cfg")
+
+ def LoadOptions(self):
+ try:
+ folder,path = self.PreferencePath()
+ values = [int(x) for x in open(path,"r").read().strip().split("#")]
+ if len(values)==3 and all(type(x) in (bool,int) and x in (0,1) for x in values):
+ self.options = map(bool, values)
+ except (IOError, OSError, ValueError, TypeError):
+ pass
+
+ def Option(self, index):
+ self.options[index] = not self.options[index]
+ try:
+ folder,path = self.PreferencePath()
+ if not os.path.isdir(folder): os.makedirs(folder)
+ with open(path,"w") as stream: stream.write("#".join(str(int(x)) for x in self.options))
+ except (IOError,OSError):
+ pass
+ self.Refresh()
+
+ def Toggle(self):
+ if self.IsShow(): self.Hide()
+ else:
+ self.mode = "research"
+ self.SetCenterPosition()
+ self.Show()
+ self.SetTop()
+ self.Query()
+
+ def Query(self):
+ now = app.GetTime()
+ self.nextQuery = now+3
+ if not self.queryStarted:self.queryStarted=now
+ net.SendChatPacket("/bio status")
+
+ def SetData(self, words):
+ if len(words)!=3:return False
+ try:index,count,deadline=map(int,words)
+ except (ValueError,TypeError):return False
+ if index not in CATALOG or count<0 or count>CATALOG[index]['count']+1 or deadline<0:return False
+ self.index,self.count,self.deadline=index,count,deadline
+ self.receivedAt=app.GetTime()
+ self.hasState=True
+ self.pendingUntil=0
+ self.Refresh()
+ return True
+
+ def SetPhase(self, index, stone):
+ try:index=int(index)
+ except (ValueError,TypeError):return
+ if index not in CATALOG:return
+ self.index=index
+ self.count=CATALOG[index]['count']+(0 if stone else 1)
+ self.hasState=True
+ self.pendingUntil=0
+ self.Refresh()
+
+ def Phase(self):
+ if not self.hasState:return "loading"
+ if self.index>=14:return "complete"
+ row=CATALOG[self.index]
+ if self.count=offer['amount'] if offer['cost'] else player.GetElk()>=offer['gold']
+ if self.mode=="reset" or self.index not in CATALOG:return False
+ row=CATALOG[self.index]
+ if player.GetStatus(player.LEVEL)0 and (self.Remaining()==0 or (self.options[1] and self.ItemCount(row['skip'])>0))
+ return phase=="stone" and self.ItemCount(row['stone'])>0
+
+ def Act(self):
+ if not self.CanAct():return
+ self.pendingUntil=app.GetTime()+5
+ if self.mode=="shop":net.SendChatPacket("/bio buy %d"%self.browse)
+ else:net.SendChatPacket("/bio mission %d %d"%(int(self.options[0]),int(self.options[1])))
+ self.nextQuery=app.GetTime()+1
+ self.Refresh()
+
+ def Gift(self, choice):
+ if app.GetTime()=len(CATALOG[index]['bonuses']):return
+ if self.mode=="reset":
+ if self.ItemCount(70028)<1 or self.bonuses[index-1]==choice+1:return
+ command="/bio reset %d %d"%(index,choice)
+ else:
+ if self.Phase()!="gift":return
+ command="/bio gift %d"%choice
+ self.pendingUntil=app.GetTime()+5
+ net.SendChatPacket(command)
+ self.nextQuery=app.GetTime()+1
+ self.Refresh()
+
+ def Shop(self):
+ self.mode="research" if self.mode=="shop" else "shop"
+ self.browse=0
+ self.Refresh()
+
+ def ResetEntries(self):
+ return [i+1 for i,value in enumerate(self.bonuses) if value and i+1=self.pendingUntil)
+ self.shopButton.SetText("返回研究" if self.mode=="shop" else "研究商店")
+ self.resetButton.SetText("返回研究" if self.mode=="reset" else "重选奖励")
+ self.message.SetText(L("请求处理中……" if app.GetTime()=14 and self.mode=="research":
+ self.title.SetText(L("全部研究已完成"))
+ self.phase.SetText(L("30~105 级研究奖励已领取。"))
+ self.icon.Hide()
+ for line in (self.material,self.progress,self.owned,self.cooldown):line.SetText("")
+ self.submit.SetText("已完成")
+ return
+ if self.mode=="shop":
+ offer=SHOP[self.browse];vnum=offer['vnum']
+ self.title.SetText(L("研究商店 %d / %d")%(self.browse+1,len(SHOP)))
+ self.phase.SetText(L("与 AVA2 生物学家商店相同的物品与兑换条件。"))
+ self.progress.SetText(L("购买数量:%d")%offer['count'])
+ cost=(self.ItemName(offer['cost'])+L(" × %d")%offer['amount']) if offer['cost'] else L("金币 %d")%offer['gold']
+ self.cooldown.SetText(L("价格:")+cost)
+ self.submit.SetText("购买")
+ for b in (self.enhance,self.skip,self.reminder):b.Hide()
+ self.prev.Show();self.next.Show()
+ else:
+ index=self.ResetIndex() if self.mode=="reset" else self.index
+ if not index:
+ self.title.SetText(L("暂无可重选的奖励"))
+ self.phase.SetText(L("完成有奖励选项的研究后,可消耗重选道具。"))
+ self.icon.Hide()
+ for line in (self.material,self.progress,self.owned,self.cooldown):line.SetText("")
+ self.submit.SetEnabled(False)
+ return
+ row=CATALOG[index]
+ phase="gift" if self.mode=="reset" else self.Phase()
+ vnum=row['stone'] if phase=="stone" else row['material']
+ self.title.SetText(L("%d 级研究 · %d / 13")%(row['level'],index))
+ text={"collect":"收集材料", "stone":"提交任务石", "gift":"选择研究奖励"}.get(phase,"")
+ self.phase.SetText(L(text))
+ self.progress.SetText(L("进度:%d / %d")%(min(self.count,row['count']) if self.mode!="reset" else row['count'],row['count']))
+ remaining=self.Remaining() if phase=="collect" else 0
+ self.cooldown.SetText(L("冷却 %02d:%02d:%02d · 基础成功率 %d%%")%(remaining/3600,(remaining/60)%60,remaining%60,row['chance']))
+ self.submit.SetText("提交任务石" if phase=="stone" else "提交材料")
+ if phase=="gift":
+ for b in (self.enhance,self.skip,self.reminder):b.Hide()
+ for i,bonus in enumerate(row['bonuses']):
+ b=self.choices[i];b.SetText(BonusText(bonus));b.Show()
+ b.SetEnabled(app.GetTime()>=self.pendingUntil and (self.mode!="reset" or (self.ItemCount(70028)>0 and self.bonuses[index-1]!=i+1)))
+ else:
+ for line,bonus in zip(self.reward,row['bonuses']):line.SetText(L(BonusText(bonus)))
+ if self.mode=="reset":
+ for b in (self.enhance,self.skip,self.reminder):b.Hide()
+ self.prev.Show();self.next.Show()
+ self.message.SetText(L("消耗重选道具 ×1,当前选择:%d")%self.bonuses[index-1])
+ elif player.GetStatus(player.LEVEL)=self.nextQuery and (self.IsShow() or not self.hasState):self.Query()
+ remaining=self.Remaining()
+ if self.options[2] and self.lastRemaining>0 and remaining==0 and self.Phase()=="collect":
+ chat.AppendChat(chat.CHAT_TYPE_NOTICE,L("[生物学家] 可以再次提交研究材料了。"))
+ self.lastRemaining=remaining
+ if not self.IsShow():return
+ dimensions=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight())
+ if dimensions!=self.dimensions:
+ self.dimensions=dimensions
+ height=min(400,dimensions[1]-12)
+ self.SetSize(540,height)
+ self.message.SetPosition(22,height-20)
+ for b in (self.submit,self.shopButton,self.resetButton):b.SetPosition(b.GetLocalPosition()[0],height-56)
+ for b in (self.enhance,self.skip,self.reminder,self.prev,self.next):b.SetPosition(b.GetLocalPosition()[0],height-112)
+ self.SetCenterPosition()
+ self.Refresh()
+ x,y=self.GetGlobalPosition();mx,my=wndMgr.GetMousePosition()
+ hover=self.iconVnum and x+24<=mx=2:
+ text={"level":"等级不足,暂时无法继续研究。","material":"研究材料不足。","cooldown":"冷却中,需要冷却药剂才能立即提交。","success":"研究成功,进度增加。","failure":"研究失败,材料已消耗。","stone":"请先准备所需任务石。","complete":"所有生物学家研究已完成。","busy":"请先关闭交易、商店或任务对话。","bought":"购买成功。","cost":"金币或兑换材料不足。","reset":"研究奖励已重新选择。","reset_item":"需要重选道具。"}.get(words[1],"")
+ if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text))
+ g_window.pendingUntil=0
+ except (ValueError,TypeError,IndexError):pass
+ return 1
+ return _command(self,line)
+
+game.GameWindow.Open=Open
+game.GameWindow.Close=Close
+game.GameWindow.BINARY_ServerCommand_Run=Command
+)PY";
diff --git a/src/platform/ScriptLib/MobileHUDScript.inc b/src/platform/ScriptLib/MobileHUDScript.inc
new file mode 100644
index 00000000..b4c887e5
--- /dev/null
+++ b/src/platform/ScriptLib/MobileHUDScript.inc
@@ -0,0 +1,280 @@
+// PORT: mobile presentation; menu actions and player data use the existing client APIs.
+static const char c_szMobileHUDScript[] = R"PY(# -*- coding: utf-8 -*-
+import app
+import game
+import grp
+import math
+import net
+import player
+import ui
+import uiCharacter
+import weakref
+import wndMgr
+import mt_mobile_inventory
+
+L = player.AutoHuntLocalize
+g_hud = None
+
+
+def _Text(parent, text):
+ line = ui.TextLine()
+ line.SetParent(parent)
+ line.AddFlag("not_pick")
+ line.SetText(L(text))
+ line.SetOutline()
+ line.Show()
+ return line
+
+
+class RoundAction(ui.Window):
+ def __init__(self, parent, text, action, icon=None):
+ ui.Window.__init__(self)
+ self.SetParent(parent)
+ self.action = action
+ self.down = False
+ self.label = _Text(self, text)
+ self.label.SetHorizontalAlignCenter()
+ self.label.SetVerticalAlignCenter()
+ self.icon = None
+ if icon:
+ self.icon = ui.ExpandedImageBox()
+ self.icon.SetParent(self)
+ self.icon.AddFlag("not_pick")
+ self.icon.LoadImage(icon)
+ self.iconSize = (self.icon.GetWidth(), self.icon.GetHeight())
+ self.icon.Show()
+ self.Show()
+
+ def Place(self, x, y, size):
+ self.SetPosition(int(x), int(y))
+ self.SetSize(int(size), int(size))
+ self.label.SetPosition(int(size/2), int(size/2))
+ if self.icon:
+ edge = size * .62
+ self.icon.SetScale(edge / max(1, self.iconSize[0]), edge / max(1, self.iconSize[1]))
+ self.icon.SetPosition(int((size-edge)/2), int((size-edge)/2))
+
+ def OnRender(self):
+ x, y = self.GetGlobalPosition()
+ r = self.GetWidth()/2.0
+ grp.SetColor(0xE0664B2D if self.down else 0xBB281A16)
+ # Horizontal strips fill a round button without requiring extra image assets.
+ for row in xrange(0, self.GetHeight(), 3):
+ dy = min(r, row + 1.5 - r)
+ half = math.sqrt(max(0, r*r - dy*dy))
+ grp.RenderBar(x+r-half, y+row, half*2, min(3, self.GetHeight()-row))
+ grp.SetColor(0xFFE6CB8D if self.down else 0xCCB99A66)
+ for i in xrange(32):
+ a = i * math.pi / 16
+ b = (i+1) * math.pi / 16
+ xx, yy = x+r+math.cos(a)*(r-1), y+r+math.sin(a)*(r-1)
+ grp.RenderLine(xx, yy, (math.cos(b)-math.cos(a))*(r-1), (math.sin(b)-math.sin(a))*(r-1))
+
+ def OnMouseLeftButtonDown(self):
+ self.down = True
+ return True
+
+ def OnMouseLeftButtonUp(self):
+ held = self.down
+ self.down = False
+ if held and self.IsIn():
+ self.action()
+ return True
+
+ def OnMouseOverOut(self):
+ self.down = False
+
+
+class Status(ui.Window):
+ def __init__(self, parent, taskbar, action):
+ ui.Window.__init__(self)
+ self.SetParent(parent)
+ self.AddFlag("not_pick")
+ self.taskbar = weakref.proxy(taskbar)
+ self.frame = ui.ExpandedImageBox()
+ self.frame.SetParent(self)
+ self.frame.AddFlag("not_pick")
+ self.frame.LoadImage("d:/ymir work/ui/game/taskbar/gauge.sub")
+ # Keep the original bar frame, cropping away the large legacy orb on its left.
+ self.frame.SetRenderingRect(-56.0/self.frame.GetWidth(), -12.0/self.frame.GetHeight(),
+ 0, (46.0-self.frame.GetHeight())/self.frame.GetHeight())
+ self.frame.Show()
+ self.gauge = taskbar.GetChild("Gauge_Board")
+ self.gaugePosition = self.gauge.GetLocalPosition()
+ self.gauge.SetParent(self)
+ self.gauge.AddFlag("not_pick")
+ self.gauge.SetAlpha(0)
+ self.gauge.Show()
+ for name in ("RampageGauge", "RampageGauge2"):
+ if taskbar.IsChild(name):
+ taskbar.GetChild(name).Hide()
+ self.face = RoundAction(self, "", action, uiCharacter.FACE_IMAGE_DICT.get(net.GetMainActorRace()))
+ self.name = _Text(self, "")
+ self.hp = _Text(self, "")
+ self.sp = _Text(self, "")
+ self.energy = _Text(self, "")
+ self.hp.SetHorizontalAlignCenter()
+ self.sp.SetHorizontalAlignCenter()
+ self.values = (0, 1, 0, 1, 0, 1)
+ self.lastStatus = None
+ self.Show()
+
+ def Place(self, scale):
+ self.scale = scale
+ self.SetPosition(int(12*scale), int(10*scale))
+ self.SetSize(int(290*scale), int(88*scale))
+ # The original three bars are native-size: their outer frame is 34 pixels tall.
+ self.face.Place(18*scale, 14*scale+12, 34)
+ self.name.SetPosition(int(66*scale), 0)
+ # The original frame, animated HP/SP textures, recovery overlays and ST strip stay intact.
+ bar_x = int(18*scale) + 34 - 56
+ self.gauge.SetPosition(bar_x, int(14*scale))
+ self.frame.SetPosition(bar_x, int(14*scale))
+ self.hp.SetHorizontalAlignLeft()
+ self.sp.SetHorizontalAlignLeft()
+ self.hp.SetPosition(bar_x+166, int(24*scale))
+ self.sp.SetPosition(bar_x+166, int(42*scale))
+ self.energy.SetPosition(int(66*scale), int(68*scale))
+
+ def Refresh(self):
+ hp, mh = player.GetStatus(player.HP), player.GetStatus(player.MAX_HP)
+ sp, ms = player.GetStatus(player.SP), player.GetStatus(player.MAX_SP)
+ exp, me = player.GetStatus(player.EXP), player.GetStatus(player.NEXT_EXP)
+ self.values = (hp, mh, sp, ms, exp, me)
+ status = self.values + (player.GetStatus(player.STAMINA), player.GetStatus(player.MAX_STAMINA),
+ player.GetStatus(player.HP_RECOVERY), player.GetStatus(player.SP_RECOVERY))
+ if status != self.lastStatus:
+ self.taskbar.RefreshStatus()
+ self.lastStatus = status
+ self.name.SetText("%s Lv.%d" % (player.GetName(), player.GetStatus(player.LEVEL)))
+ self.hp.SetText("%d / %d" % (hp, mh) if mh > 0 else "--")
+ self.sp.SetText("%d / %d" % (sp, ms) if ms > 0 else "--")
+ energy = player.GetStatus(player.ENERGY)
+ remaining = player.GetStatus(player.ENERGY_END_TIME) - app.GetGlobalTimeStamp()
+ if energy > 0 and remaining > 0:
+ self.energy.SetText(L("能量 +%d %d分钟") % (energy, (remaining+59)/60))
+ self.energy.Show()
+ else:
+ self.energy.Hide()
+
+ def Restore(self):
+ self.gauge.Hide()
+ self.gauge.SetAlpha(1)
+ self.gauge.SetParent(self.taskbar)
+ self.gauge.SetPosition(*self.gaugePosition)
+ self.face.action = None
+
+
+class HUD(ui.Window):
+ def __init__(self, window):
+ ui.Window.__init__(self)
+ self.SetWindowName("MobileHUD")
+ self.AddFlag("not_pick")
+ self.window = weakref.proxy(window)
+ interface = window.interface
+ self.taskbar = weakref.proxy(interface.wndTaskBar)
+ self.original = (self.taskbar.Show, self.taskbar.Hide, self.taskbar.IsShow)
+ self.original[1]()
+ self.taskbar.Show = self.Show
+ self.taskbar.Hide = self.Hide
+ self.taskbar.IsShow = self.IsShow
+ self.chat = weakref.proxy(interface.wndChat)
+ self.chatColors = (self.chat.BOARD_START_COLOR, self.chat.BOARD_END_COLOR, self.chat.BOARD_MIDDLE_COLOR)
+ self.chat.BOARD_START_COLOR = self.chat.BOARD_END_COLOR = self.chat.BOARD_MIDDLE_COLOR = 0
+ self.status = Status(self, interface.wndTaskBar, interface.ToggleCharacterWindowStatusPage)
+ self.experience = self.taskbar.expGaugeBoard
+ self.experiencePosition = self.experience.GetLocalPosition()
+ self.experience.SetParent(self)
+ self.experience.AddFlag("not_pick")
+ self.experience.Show()
+ for point in self.taskbar.expGauge:
+ point.AddFlag("not_pick")
+ self.experienceText = _Text(self.experience, "")
+ self.top = []
+ for text, index, action in (("", 0, interface.ToggleCharacterWindowStatusPage),
+ ("", 1, interface.ToggleInventoryWindow),
+ ("", 2, interface.ToggleMessenger),
+ ("", 3, interface.ToggleSystemDialog)):
+ self.top.append(RoundAction(self, text, action, self.taskbar.toggleButtonDict[index].GetUpVisualFileName()))
+ self.side = [RoundAction(self, "任务", lambda: interface.ToggleCharacterWindow("QUEST")),
+ RoundAction(self, "帮助", interface.OpenHelpWindow),
+ RoundAction(self, "聊天", interface.ToggleChat)]
+ self.dimensions = None
+ self.OnUpdate()
+ self.Show()
+
+ def Show(self):
+ ui.Window.Show(self)
+ self.SetTop()
+
+ def OnUpdate(self):
+ w, h = wndMgr.GetScreenWidth(), wndMgr.GetScreenHeight()
+ target = max(48, mt_mobile_inventory.touch_target)
+ dimensions = (w, h, target)
+ if dimensions != self.dimensions:
+ self.dimensions = dimensions
+ s = max(target/52.0, min(1.35, max(.65, min(h/600.0, w/800.0))))
+ self.SetSize(w, h)
+ self.status.Place(s)
+ self.experience.SetPosition(int(12*s), int(h-self.experience.GetHeight()-6*s))
+ self.experienceText.SetPosition(self.experience.GetWidth()+6, 10)
+ size, gap = max(target, 48*s), 8*s
+ for i, button in enumerate(self.top):
+ button.Place(w-12*s-(4-i)*(size+gap), 10*s, size)
+ for i, button in enumerate(self.side):
+ if h < 520 or s > 1.25:
+ button.Place(12*s+i*(size+gap), 140*s, size)
+ else:
+ button.Place(12*s, 140*s+i*(size+gap), size)
+ self.window.affectShower.SetPosition(int(18*s), int(104*s))
+ minimap = self.window.interface.wndMiniMap
+ minimap.SetWindowHorizontalAlignLeft()
+ minimap.SetWindowVerticalAlignTop()
+ minimap.SetPosition(int(w-minimap.GetWidth()-12*s), int(72*s))
+ chat = self.window.interface.wndChat
+ chat.SetPosition(int(180*s), int(h-86*s))
+ chat.Refresh()
+ # The original help prompt would duplicate the touch-sized left-side entry.
+ self.window.interface.wndGameButton.gameButtonDict["HELP"].Hide()
+ energy = self.window.interface.wndEnergyBar
+ if energy:
+ energy.Hide()
+ self.status.Refresh()
+ # Match TaskBar.SetExperience's unsigned 32-bit experience counters.
+ exp, total = (value & 0xffffffff for value in self.status.values[4:6])
+ self.experienceText.SetText((L("经验 %.2f%%") % (100.0*exp/total)) if total > 0 else L("经验 --"))
+
+ def Destroy(self):
+ self.Hide()
+ self.chat.BOARD_START_COLOR, self.chat.BOARD_END_COLOR, self.chat.BOARD_MIDDLE_COLOR = self.chatColors
+ self.taskbar.Show, self.taskbar.Hide, self.taskbar.IsShow = self.original
+ self.status.Restore()
+ self.experience.Hide()
+ self.experience.SetParent(self.taskbar)
+ self.experience.SetPosition(*self.experiencePosition)
+ for button in self.top + self.side:
+ button.action = None
+ self.window = None
+
+
+_open = game.GameWindow.Open
+_close = game.GameWindow.Close
+
+
+def Open(self):
+ global g_hud
+ _open(self)
+ g_hud = HUD(self)
+
+
+def Close(self):
+ global g_hud
+ if g_hud:
+ g_hud.Destroy()
+ g_hud = None
+ _close(self)
+
+
+game.GameWindow.Open = Open
+game.GameWindow.Close = Close
+)PY";
diff --git a/src/platform/ScriptLib/MobileInventoryScript.inc b/src/platform/ScriptLib/MobileInventoryScript.inc
new file mode 100644
index 00000000..575d4cdd
--- /dev/null
+++ b/src/platform/ScriptLib/MobileInventoryScript.inc
@@ -0,0 +1,125 @@
+// PORT: touch inventory gestures; original methods own use, move and sale requests.
+static const char c_szMobileInventoryScript[] = R"PY(# -*- coding: utf-8 -*-
+import app
+import constInfo
+import game
+import mouseModule
+import player
+import shop
+import uiInventory
+import uiShop
+
+# These flags are scoped by the native touch controller, never by desktop mouse input.
+tap = False
+double_tap = False
+dragging = False
+touch_target = 48
+_last = None
+_last_time = -1
+_drag = None
+_OriginalSelect = uiInventory.InventoryWindow.SelectItemSlot
+_OriginalUse = uiInventory.InventoryWindow.UseItemSlot
+_OriginalShopLoad = uiShop.ShopDialog.LoadDialog
+
+
+def _ShopLoad(self):
+ result = _OriginalShopLoad(self)
+ # The original loader names child windows but leaves this root unnamed.
+ self.SetWindowName("ShopDialog")
+ return result
+
+
+def _Snapshot(owner, slot):
+ import weakref
+ cell = owner._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slot)
+ return (owner if isinstance(owner, weakref.ProxyTypes) else weakref.proxy(owner), owner.inventoryPageIndex, cell,
+ player.GetItemIndex(cell), player.GetItemCount(cell))
+
+
+def _Valid(snapshot):
+ if not snapshot:
+ return False
+ try:
+ owner, page, cell, vnum, count = snapshot
+ return (owner.IsShow() and owner.inventoryPageIndex == page and vnum != 0 and
+ player.GetItemIndex(cell) == vnum and player.GetItemCount(cell) == count and
+ not constInfo.GET_ITEM_QUESTION_DIALOG_STATUS())
+ except ReferenceError:
+ return False
+
+
+def _Select(self, slot):
+ global _last, _last_time, _drag
+ mouse = mouseModule.mouseController
+ if tap and not mouse.isAttached() and not constInfo.GET_ITEM_QUESTION_DIALOG_STATUS() and app.GetCursor() not in (app.SELL, app.BUY):
+ _last = _Snapshot(self, slot)
+ _last_time = app.GetTime()
+ if _last[3]:
+ self.ShowToolTip(_last[2])
+ return
+ snapshot = _Snapshot(self, slot) if dragging and not mouse.isAttached() else None
+ result = _OriginalSelect(self, slot)
+ if snapshot and mouse.isAttached() and mouse.GetAttachedType() == player.SLOT_TYPE_INVENTORY:
+ _drag = snapshot
+ return result
+
+
+def _Use(self, slot):
+ global _last
+ if double_tap:
+ snapshot = _last
+ _last = None
+ if (not _Valid(snapshot) or not 0 <= app.GetTime()-_last_time <= .5 or
+ snapshot != _Snapshot(self, slot)):
+ return
+ return _OriginalUse(self, slot)
+
+
+def SetDrag(active):
+ global dragging, _drag, _last
+ dragging = active
+ _drag = None
+ _last = None
+
+
+def Drop(shop_target):
+ """Consume stale inventory drags and NPC-shop drops; other targets use the normal UI path."""
+ global _drag
+ mouse = mouseModule.mouseController
+ if not _drag or not mouse.isAttached():
+ return False
+ snapshot = _drag
+ if (not _Valid(snapshot) or mouse.GetAttachedType() != player.SLOT_TYPE_INVENTORY or
+ mouse.GetAttachedSlotNumber() != snapshot[2] or mouse.GetAttachedItemIndex() != snapshot[3] or
+ mouse.GetAttachedItemCount() != snapshot[4]):
+ mouse.DeattachObject()
+ _drag = None
+ return True
+ if not shop_target or not shop.IsOpen() or shop.IsPrivateShop():
+ return False
+ # Always use the original inventory sale dialog, including anti-sell checks and identity recheck.
+ owner, page, cell, vnum, count = snapshot
+ mouse.DeattachObject()
+ _drag = None
+ owner._InventoryWindow__SellItem(cell)
+ return True
+
+
+def Reset():
+ global tap, double_tap, dragging, _last, _drag
+ tap = double_tap = dragging = False
+ _last = _drag = None
+
+
+_Close = game.GameWindow.Close
+
+def Close(self):
+ Reset()
+ _Close(self)
+
+
+uiInventory.InventoryWindow.SelectItemSlot = _Select
+uiInventory.InventoryWindow.UseItemSlot = _Use
+uiShop.ShopDialog.LoadDialog = _ShopLoad
+game.GameWindow.Close = Close
+)PY";
diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp
index c7e7a64d..9449626b 100644
--- a/src/platform/ScriptLib/PythonBoot.cpp
+++ b/src/platform/ScriptLib/PythonBoot.cpp
@@ -161,6 +161,7 @@ struct GameSingletons
std::unique_ptr g_game_singletons;
bool g_host_hardware_cursor_enabled = false;
bool g_touch_input = false;
+int g_inventory_touch_target = 0;
bool fail(std::string* error, const std::string& text)
{
@@ -526,6 +527,76 @@ bool quick_slot_image_uv(CGraphicImage* image, std::string* texture, float uv[4]
}
}
+// PORT: presentation only; skill validation remains in RequestUseLocalQuickSlot.
+QuickSlotState LocalQuickSlotState(int localSlotIndex)
+{
+ QuickSlotState state;
+ if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return state;
+ DWORD type = SLOT_TYPE_NONE, position = 0;
+ auto& player = CPythonPlayer::Instance();
+ player.GetLocalQuickSlotData(static_cast(localSlotIndex), &type, &position);
+ if (type == SLOT_TYPE_SKILL) {
+ state.learned = player.GetSkillLevel(position) > 0;
+ state.active = player.IsSkillActive(position);
+ if (player.IsSkillCoolTime(position))
+ state.remaining = std::max(0.0f, player.GetSkillCoolTime(position) - player.GetSkillElapsedCoolTime(position));
+ }
+ return state;
+}
+
+void SetInventoryTouchTarget(float logicalSize)
+{
+ if (!g_touch_input || !g_launcher) return;
+ const int size = std::max(48, int(std::ceil(logicalSize)));
+ if (size == g_inventory_touch_target) return;
+ std::string error;
+ if (RunLine(("__import__('mt_mobile_inventory').touch_target = " + std::to_string(size)).c_str(), &error))
+ g_inventory_touch_target = size;
+}
+
+void SetInventoryTap(bool tap)
+{
+ if (!g_touch_input) return;
+ std::string error;
+ RunLine(tap ? "__import__('mt_mobile_inventory').tap = True" :
+ "__import__('mt_mobile_inventory').tap = False", &error);
+}
+
+void SetInventoryDoubleTap(bool tap)
+{
+ if (!g_touch_input) return;
+ std::string error;
+ RunLine(tap ? "__import__('mt_mobile_inventory').double_tap = True" :
+ "__import__('mt_mobile_inventory').double_tap = False", &error);
+}
+
+void SetInventoryDrag(bool dragging)
+{
+ if (!g_touch_input) return;
+ std::string error;
+ RunLine(dragging ? "__import__('mt_mobile_inventory').SetDrag(True)" :
+ "__import__('mt_mobile_inventory').SetDrag(False)", &error);
+}
+
+bool FinishInventoryDrag(int x, int y)
+{
+ if (!g_touch_input || !g_window_manager) return false;
+ UIMouseMove(x, y);
+ bool shop_target = false;
+ for (auto* picked = g_window_manager->GetPointWindow(); picked; picked = picked->GetParent())
+ if (std::string(picked->GetName()) == "ShopDialog") {
+ shop_target = true;
+ break;
+ }
+ std::string result, error;
+ if (!Evaluate(shop_target ? "__import__('mt_mobile_inventory').Drop(True)" :
+ "__import__('mt_mobile_inventory').Drop(False)", &result, &error)) {
+ UIDeattachObject();
+ return true; // Never fall back to a direct shop sale after a failed confirmation handler.
+ }
+ return result == "True" || result == "1";
+}
+
bool LocalQuickSlotIcon(int localSlotIndex, std::string* texture, float uv[4], int* count)
{
if (!g_game_singletons || localSlotIndex < 0 || localSlotIndex >= 8) return false;
@@ -915,8 +986,11 @@ namespace PythonBoot
#include "FrameRateOptionScript.inc"
#include "UIScaleOptionScript.inc"
#include "InventoryPagesScript.inc"
+#include "MobileInventoryScript.inc"
#include "MallHintScript.inc"
#include "ServerBonusScript.inc"
+#include "MobileHUDScript.inc"
+#include "BiologScript.inc"
static void install_script_module(const char* c_szModule, const char* c_szScript)
{
@@ -975,8 +1049,15 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
install_script_module("mt_framerate", c_szFrameRateOptionScript);
install_script_module("mt_uiscale", c_szUIScaleOptionScript);
install_script_module("mt_inventory", c_szInventoryPagesScript);
+ if (g_touch_input) {
+ g_inventory_touch_target = 0;
+ install_script_module("mt_mobile_inventory", c_szMobileInventoryScript);
+ }
install_script_module("mt_mallhint", c_szMallHintScript);
install_script_module("mt_bonuses", c_szServerBonusScript);
+ install_script_module("mt_biolog", c_szBiologScript);
+ if (g_touch_input)
+ install_script_module("mt_mobile_hud", c_szMobileHUDScript);
}
return true;
}
diff --git a/src/platform/ScriptLib/PythonBoot.h b/src/platform/ScriptLib/PythonBoot.h
index dce18aab..9402be11 100644
--- a/src/platform/ScriptLib/PythonBoot.h
+++ b/src/platform/ScriptLib/PythonBoot.h
@@ -87,6 +87,13 @@ bool AutoHuntIsEnabled();
bool AutoHuntAvailable();
// The skill or inventory item in the local quick slot: its UI texture name, the icon's (su, sv, eu, ev)
// inside it and the item count (0 for a skill).
+struct QuickSlotState { float remaining = 0; bool learned = true; bool active = false; };
+QuickSlotState LocalQuickSlotState(int localSlotIndex);
+void SetInventoryTap(bool tap);
+void SetInventoryDoubleTap(bool tap);
+void SetInventoryDrag(bool dragging);
+bool FinishInventoryDrag(int x, int y);
+void SetInventoryTouchTarget(float logicalSize);
bool LocalQuickSlotIcon(int localSlotIndex, std::string* texture, float uv[4], int* count);
// Touch gestures: a Win32 WM_LBUTTONDBLCLK (40250 CPythonApplication::OnMouseLeftButtonDoubleClick),
// whether an icon hangs on the cursor (CWindowManager::IsAttaching) and mouseController.DeattachObject().
diff --git a/src/port/CMakeLists.txt b/src/port/CMakeLists.txt
index 6d94c23d..3dd84f59 100644
--- a/src/port/CMakeLists.txt
+++ b/src/port/CMakeLists.txt
@@ -196,6 +196,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
add_executable(port_text_test ${PROJECT_SOURCE_DIR}/tests/port/port_text_test.cpp)
add_executable(host_ui_quality_test ${PROJECT_SOURCE_DIR}/tests/host/ui_quality_test.cpp)
add_test(NAME host.ui_quality COMMAND host_ui_quality_test)
+ add_executable(host_touch_layout_test ${PROJECT_SOURCE_DIR}/tests/host/touch_layout_test.cpp)
+ add_test(NAME host.touch_layout COMMAND host_touch_layout_test)
add_executable(host_autohunt_pull_test ${PROJECT_SOURCE_DIR}/tests/host/autohunt_pull_test.cpp)
add_test(NAME host.autohunt_pull COMMAND host_autohunt_pull_test)
add_executable(host_autohunt_navigation_test ${PROJECT_SOURCE_DIR}/tests/host/autohunt_navigation_test.cpp)
@@ -301,6 +303,7 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
${PROJECT_SOURCE_DIR}/tests/port/port_login_flow_server.cpp
${PROJECT_SOURCE_DIR}/tests/port/classic/classic_cipher.cpp)
target_link_libraries(port_login_flow_test PRIVATE port_platform)
+ target_include_directories(port_login_flow_test PRIVATE "${PROJECT_SOURCE_DIR}/src" "${PROJECT_SOURCE_DIR}/src/platform/EterLib")
add_dependencies(port_login_flow_test mtpython_stdlib)
add_test(NAME port.login_flow
COMMAND $ ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP})
@@ -308,6 +311,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
# Offline locale data builder: retains 40250 proto layout and gameplay fields.
add_executable(locale_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/locale_proto_patch.cpp)
target_link_libraries(locale_proto_patch PRIVATE port_platform)
+ add_executable(biolog_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/biolog_proto_patch.cpp)
+ target_link_libraries(biolog_proto_patch PRIVATE port_platform)
# Locale packs in the original format (scripts/build_zh_locale.py packs locale/zh with it).
add_executable(eterpack_pack ${PROJECT_SOURCE_DIR}/tests/port/eterpack_pack.cpp)
target_link_libraries(eterpack_pack PRIVATE port_platform)
diff --git a/src/port/EterLib/NetStream.cpp b/src/port/EterLib/NetStream.cpp
index 42178559..87528b78 100644
--- a/src/port/EterLib/NetStream.cpp
+++ b/src/port/EterLib/NetStream.cpp
@@ -422,6 +422,7 @@ bool CNetworkStream::Connect(const CNetworkAddress& c_rkNetAddr, int limitSec)
if (error != WSAEWOULDBLOCK)
{
+ std::fprintf(stderr, "[network] connect socket=%d error=%d (%s)\n", m_sock, error, std::strerror(error));
Tracen("error != WSAEWOULDBLOCK");
Clear();
OnConnectFailure();
diff --git a/src/port/UserInterface/PythonPlayer.h b/src/port/UserInterface/PythonPlayer.h
index 46ee704d..28e250e2 100644
--- a/src/port/UserInterface/PythonPlayer.h
+++ b/src/port/UserInterface/PythonPlayer.h
@@ -738,6 +738,7 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer
std::vector AutoHunt_GetAvailableSkills();
void AutoHunt_SetSkills(const std::vector& indices);
void AutoHunt_OnDeath();
+ void AutoHunt_OnHit(DWORD victim); // PORT: actual collision hit, not attack animation
int AutoHunt_GetMountState() const { return m_iAutoHuntMountState; }
const SAutoHuntConfig& AutoHunt_GetConfig();
void AutoHunt_SetConfig(const SAutoHuntConfig& c_rkConfig);
@@ -759,7 +760,9 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer
void __AutoHunt_ResetMovement();
void __AutoHunt_MigrateSkills();
bool __AutoHunt_IsTargetAllowed(CInstanceBase& main, CInstanceBase& target); // PORT extension
- bool __AutoHunt_RequestPull(CInstanceBase& main, float now); // PORT extension
+ DWORD __AutoHunt_FindCloseTarget(CInstanceBase& main, int* count = nullptr);
+ bool __AutoHunt_CanAttackHere(CInstanceBase& main, CInstanceBase& target);
+ bool __AutoHunt_RequestPull(CInstanceBase& main, float now); // PORT extension
DWORD __AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow);
DWORD __AutoHunt_FindGatheredTarget(CInstanceBase& rkInstMain);
void __AutoHunt_PurgeGathered();
@@ -769,7 +772,9 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer
void __AutoHunt_LoadConfig();
void __AutoHunt_SaveConfig();
- bool m_isAutoHuntLegacySkills = false;
+ float m_fAutoHuntCrowdUntil = 0, m_fAutoHuntItemStart = 0;
+ float m_fAutoHuntWaypointBest = 1e9f;
+ bool m_isAutoHuntLegacySkills = false;
int m_iAutoHuntMountState = 0; // 0 normal, 1 wait dismount, 2 cast, 3 wait remount
DWORD m_dwAutoHuntPendingSkill = 0;
float m_fAutoHuntMountDeadline = 0, m_fAutoHuntMountNextTime = 0;
diff --git a/src/port/UserInterface/PythonPlayerAutoHunt.cpp b/src/port/UserInterface/PythonPlayerAutoHunt.cpp
index 8718f139..32c28968 100644
--- a/src/port/UserInterface/PythonPlayerAutoHunt.cpp
+++ b/src/port/UserInterface/PythonPlayerAutoHunt.cpp
@@ -288,6 +288,7 @@ void CPythonPlayer::__AutoHunt_Stop(int eReason)
m_iAutoHuntMountState = 0;
if (auto* main=NEW_GetMainActorPtr()) main->NEW_Stop();
m_eAutoHuntState = AUTO_HUNT_OFF;
+ m_fAutoHuntCrowdUntil = 0;
m_eAutoHuntStopReason = eReason;
m_dwAutoHuntTargetVID = 0;
m_dwAutoHuntItemID = 0;
@@ -345,11 +346,48 @@ bool CPythonPlayer::__AutoHunt_IsTargetAllowed(CInstanceBase& main, CInstanceBas
return m_kAutoHuntConfig.bAttackBoss || !mob || mob->bRank < c_byMobRankBoss;
}
+// PORT: local combat can interrupt loot/blocked pursuit without changing manual input.
+void CPythonPlayer::AutoHunt_OnHit(DWORD victim)
+{
+ if (AutoHunt_IsEnabled() && m_eAutoHuntState == AUTO_HUNT_ENGAGE && victim == m_dwAutoHuntTargetVID)
+ m_fAutoHuntProgressTime = CTimer::Instance().GetCurrentSecond();
+}
+
+bool CPythonPlayer::__AutoHunt_CanAttackHere(CInstanceBase& main, CInstanceBase& target)
+{
+ if (main.IsInSafe() || !main.NEW_IsClickableDistanceDestInstance(target)) return false;
+ TPixelPosition goal; target.NEW_GetPixelPosition(&goal);
+ std::vector line;
+ return __AutoHunt_BuildPath(goal, 0.f, line) && line.size() == 1 && PixelDistance(line.back(), goal) < 1.f;
+}
+
+DWORD CPythonPlayer::__AutoHunt_FindCloseTarget(CInstanceBase& main, int* count)
+{
+ DWORD best = 0; float distance = 1e9f; int nearby = 0;
+ auto& manager = CPythonCharacterManager::Instance();
+ for (auto i = manager.CharacterInstanceBegin(); i != manager.CharacterInstanceEnd(); ++i) {
+ auto* actor = *i;
+ if (!actor || !__AutoHunt_IsTargetAllowed(main, *actor) || actor->IsStone() ||
+ main.GetDistance(actor) > c_fAutoHuntSkillDistance ||
+ !main.NEW_IsClickableDistanceDestInstance(*actor)) continue;
+ if (!__AutoHunt_CanAttackHere(main, *actor)) continue;
+ ++nearby;
+ if (actor->GetVirtualID() == m_dwAutoHuntTargetVID) best = actor->GetVirtualID();
+ if (best == m_dwAutoHuntTargetVID && best) continue;
+ const float d = main.GetDistance(actor);
+ if (d < distance) { distance = d; best = actor->GetVirtualID(); }
+ }
+ if (count) *count = nearby;
+ return best;
+}
+
// PORT: cloak collection and the server pursuit leash follow the hunting range.
bool CPythonPlayer::__AutoHunt_RequestPull(CInstanceBase& main, float now)
{
const int maxHP = GetStatus(POINT_MAX_HP);
- if (m_kAutoHuntConfig.iGatherCount <= 0 || m_isAutoHuntGathering || now < m_fAutoHuntNextPullTime ||
+ int nearby = 0; __AutoHunt_FindCloseTarget(main, &nearby);
+ if (nearby >= 2 || now < m_fAutoHuntCrowdUntil ||
+ m_kAutoHuntConfig.iGatherCount <= 0 || m_isAutoHuntGathering || now < m_fAutoHuntNextPullTime ||
main.IsInSafe() || maxHP <= 0 || GetStatus(POINT_HP) * 100 < maxHP * c_iAutoHuntGatherHPPercent)
return false;
if (m_kAutoHuntConfig.bAttackStone) {
@@ -738,6 +776,28 @@ void CPythonPlayer::__Update_AutoHunt()
return;
if (__AutoHunt_UpdateMount(rkInstMain,fNow)) return;
+ // PORT: finish local combat before moving through a pack to loot or pursue.
+ if (m_eAutoHuntState == AUTO_HUNT_SEARCH || m_eAutoHuntState == AUTO_HUNT_RETURN ||
+ m_eAutoHuntState == AUTO_HUNT_LOOT || m_eAutoHuntState == AUTO_HUNT_ENGAGE) {
+ int nearby = 0;
+ const DWORD close = __AutoHunt_FindCloseTarget(rkInstMain, &nearby);
+ const bool stalled = m_eAutoHuntState == AUTO_HUNT_ENGAGE &&
+ fNow - m_fAutoHuntProgressTime > 1.5f;
+ const bool interruptLoot = m_eAutoHuntState == AUTO_HUNT_LOOT && close;
+ if (nearby >= 2 || (close && stalled) || interruptLoot) {
+ m_fAutoHuntCrowdUntil = fNow + 3.f;
+ if (close && (m_eAutoHuntState != AUTO_HUNT_ENGAGE || close != m_dwAutoHuntTargetVID)) {
+ if (m_dwAutoHuntItemID) m_kMap_dwAutoHuntSkipItem[m_dwAutoHuntItemID] = fNow + 2.f;
+ m_dwAutoHuntItemID = 0;
+ __ClearReservedAction(); __ClearAutoAttackTargetActorID();
+ rkInstMain.NEW_Stop();
+ AutoHuntTrace("local combat interrupts state %d: target %u", m_eAutoHuntState, unsigned(close));
+ __AutoHunt_Engage(close, fNow);
+ }
+ }
+ }
+
+
switch (m_eAutoHuntState)
{
case AUTO_HUNT_PAUSED:
@@ -861,7 +921,7 @@ void CPythonPlayer::__Update_AutoHunt()
return;
}
- if (m_kAutoHuntConfig.bAttackStone && !pkInstVictim->IsStone()) {
+ if (fNow >= m_fAutoHuntCrowdUntil && m_kAutoHuntConfig.bAttackStone && !pkInstVictim->IsStone()) {
const DWORD preferred = __AutoHunt_FindTarget(rkInstMain, fNow);
CInstanceBase* actor = NEW_FindActorPtr(preferred);
if (actor && actor->IsStone()) {
@@ -878,7 +938,7 @@ void CPythonPlayer::__Update_AutoHunt()
m_fAutoHuntBestDistance = fDistance;
m_fAutoHuntProgressTime = fNow;
}
- if (rkInstMain.IsAttacking() || rkInstMain.IsUsingSkill())
+ if (rkInstMain.IsUsingSkill() || rkInstMain.IsStun())
{
m_fAutoHuntProgressTime = fNow;
}
@@ -888,7 +948,8 @@ void CPythonPlayer::__Update_AutoHunt()
if (isBlockedBySafeZone || fNow - m_fAutoHuntProgressTime > c_fAutoHuntNoProgressTime ||
fNow - m_fAutoHuntStateTime > c_fAutoHuntTargetTimeout)
{
- m_kMap_dwAutoHuntSkipVID[m_dwAutoHuntTargetVID] = fNow + c_fAutoHuntSkipTime;
+ m_kMap_dwAutoHuntSkipVID[m_dwAutoHuntTargetVID] = fNow + 3.f;
+ m_fAutoHuntCrowdUntil = fNow + 3.f;
__ClearAutoAttackTargetActorID();
__ClearReservedAction(); __AutoHunt_ResetMovement(); rkInstMain.NEW_Stop();
m_dwAutoHuntTargetVID = 0;
@@ -899,7 +960,10 @@ void CPythonPlayer::__Update_AutoHunt()
TPixelPosition goal; pkInstVictim->NEW_GetPixelPosition(&goal);
// Use the planner even in attack range: never swing or cast through a wall.
- if (!__AutoHunt_MoveTo(goal,100.f,fNow)) {
+ if (__AutoHunt_CanAttackHere(rkInstMain, *pkInstVictim)) {
+ if (!m_vecAutoHuntPath.empty()) rkInstMain.NEW_Stop();
+ __AutoHunt_ResetMovement();
+ } else if (!__AutoHunt_MoveTo(goal,100.f,fNow)) {
m_kMap_dwAutoHuntSkipVID[m_dwAutoHuntTargetVID]=fNow+c_fAutoHuntSkipTime;
__ClearAutoAttackTargetActorID(); __ClearReservedAction(); rkInstMain.NEW_Stop();
m_dwAutoHuntTargetVID=0; m_eAutoHuntState=AUTO_HUNT_SEARCH; return;
@@ -936,10 +1000,12 @@ void CPythonPlayer::__Update_AutoHunt()
{
m_dwAutoHuntItemID = 0; // picked up (or gone)
}
- else if (fNow - m_fAutoHuntProgressTime > c_fAutoHuntItemTimeout)
+ else if (fNow - m_fAutoHuntProgressTime > c_fAutoHuntItemTimeout ||
+ fNow - m_fAutoHuntItemStart > c_fAutoHuntLootTimeout)
{
m_kMap_dwAutoHuntSkipItem[m_dwAutoHuntItemID] = fNow + c_fAutoHuntItemSkipTime;
m_dwAutoHuntItemID = 0;
+ __AutoHunt_ResetMovement(); rkInstMain.NEW_Stop();
__ClearReservedAction();
}
else
@@ -970,6 +1036,7 @@ void CPythonPlayer::__Update_AutoHunt()
// __OnPressItem without its once-per-item guard.
m_dwAutoHuntItemID = dwItemID;
+ m_fAutoHuntItemStart = fNow;
m_fAutoHuntProgressTime = fNow;
__ClearReservedAction();
if (rkInstMain.NEW_IsClickableDistanceDestPixelPosition(kPPosItem))
@@ -995,6 +1062,7 @@ void CPythonPlayer::__AutoHunt_ResetMovement()
m_vecAutoHuntPath.clear();
m_fAutoHuntPathTime=0;
m_fAutoHuntPathMoveTime=0;
+ m_fAutoHuntWaypointBest=1e9f;
m_fAutoHuntTargetScanTime=0;
}
@@ -1046,8 +1114,10 @@ bool CPythonPlayer::__AutoHunt_MoveTo(const TPixelPosition& goal, float arrival,
m_kAutoHuntPathGoal=goal; m_fAutoHuntPathTime=now;
m_fAutoHuntPathMoveTime=0;
}
- while (!m_vecAutoHuntPath.empty() && PixelDistance(here,m_vecAutoHuntPath.front())<=50.f)
+ while (!m_vecAutoHuntPath.empty() && PixelDistance(here,m_vecAutoHuntPath.front())<=50.f) {
m_vecAutoHuntPath.erase(m_vecAutoHuntPath.begin());
+ m_fAutoHuntWaypointBest=1e9f; m_fAutoHuntProgressTime=now;
+ }
// The final planned point may be an accessible attack/pickup position beside a blocked target cell.
if (m_vecAutoHuntPath.size()==1 && PixelDistance(here,m_vecAutoHuntPath.front())<=arrival)
m_vecAutoHuntPath.clear();
@@ -1059,9 +1129,10 @@ bool CPythonPlayer::__AutoHunt_MoveTo(const TPixelPosition& goal, float arrival,
main->NEW_MoveToDestPixelPositionDirection(m_vecAutoHuntPath.front());
}
// Waypoint progress can temporarily increase distance to the enemy while going around a wall.
- if (main->IsWalking() && PixelDistance(here,m_kAutoHuntPathLastPosition)>30.f) {
+ const float remaining=PixelDistance(here,m_vecAutoHuntPath.front());
+ if (remaining < m_fAutoHuntWaypointBest-30.f) {
m_fAutoHuntProgressTime=now;
- m_kAutoHuntPathLastPosition=here;
+ m_fAutoHuntWaypointBest=remaining;
}
return true;
}
diff --git a/src/port/UserInterface/PythonPlayerEventHandler.cpp b/src/port/UserInterface/PythonPlayerEventHandler.cpp
index 8de2d34b..29815d13 100644
--- a/src/port/UserInterface/PythonPlayerEventHandler.cpp
+++ b/src/port/UserInterface/PythonPlayerEventHandler.cpp
@@ -135,6 +135,8 @@ void CPythonPlayerEventHandler::OnChangeShape()
void CPythonPlayerEventHandler::OnHit(UINT uSkill, CActorInstance& rkActorVictim, BOOL isSendPacket)
{
DWORD dwVIDVictim=rkActorVictim.GetVirtualID();
+ // PORT: auto-hunt progress is driven by a hit rather than animation state.
+ if (isSendPacket) CPythonPlayer::Instance().AutoHunt_OnHit(dwVIDVictim);
// Update Target
CPythonPlayer::Instance().SetTarget(dwVIDVictim, FALSE);
diff --git a/tests/host/touch_layout_test.cpp b/tests/host/touch_layout_test.cpp
new file mode 100644
index 00000000..ed3c6e7d
--- /dev/null
+++ b/tests/host/touch_layout_test.cpp
@@ -0,0 +1,42 @@
+#include "../../src/host/touch_layout.h"
+#include
+
+int main() {
+ int failures = 0;
+ auto check = [&](bool ok, const char* message) {
+ if (!ok) { std::fprintf(stderr, "FAIL: %s\n", message); ++failures; }
+ };
+ struct Screen { float width, height, inset, target; };
+ for (const auto screen : {Screen{640, 360, 24, 48}, Screen{800, 600, 0, 48},
+ Screen{1300, 600, 40, 48}, Screen{1067, 600, 32, 72},
+ Screen{1366, 1024, 62, 48}}) {
+ const auto layout = mt_host::make_touch_layout(screen.width, screen.height, screen.inset, screen.target);
+ const auto& attack = layout.buttons[0];
+ float previous_angle = -1000;
+ float first_distance = 0;
+ for (int i = 0; i < int(layout.buttons.size()); ++i) {
+ const auto& b = layout.buttons[i];
+ check(b.x - b.radius >= screen.inset && b.x + b.radius <= screen.width - screen.inset &&
+ b.y - b.radius >= 0 && b.y + b.radius <= screen.height, "visible controls stay inside the safe area");
+ check(2 * b.radius >= screen.target - 0.01f, "controls retain minimum touch size");
+ if (i >= 1 && i <= 6) {
+ check(b.y + b.radius <= screen.height - 40 * layout.scale,
+ "skills leave the bottom status and menu strip clear");
+ const float dx = b.x - attack.x, dy = b.y - attack.y;
+ const float distance = std::hypot(dx, dy);
+ if (i == 1) first_distance = distance;
+ check(std::abs(distance - first_distance) < 0.01f, "all six skills occupy one ring");
+ float angle = std::atan2(dy, dx);
+ if (angle < 0) angle += 6.2831853f;
+ check(angle > previous_angle, "skill order follows the arc without reversing");
+ previous_angle = angle;
+ }
+ for (int j = 0; j < i; ++j) {
+ const auto& other = layout.buttons[j];
+ check(std::hypot(b.x - other.x, b.y - other.y) > 1.25f * (b.radius + other.radius),
+ "expanded touch areas cannot activate a neighboring control");
+ }
+ }
+ }
+ return failures ? 1 : 0;
+}
diff --git a/tests/port/autohunt_crowd_checks.inc b/tests/port/autohunt_crowd_checks.inc
new file mode 100644
index 00000000..78bf0ca8
--- /dev/null
+++ b/tests/port/autohunt_crowd_checks.inc
@@ -0,0 +1,43 @@
+// PORT: loot must yield to local combat, including actors outside the pull reply.
+if (server && CPythonPlayer::Instance().NEW_GetMainActorPtr()) {
+ auto& player = CPythonPlayer::Instance();
+ auto* main = player.NEW_GetMainActorPtr();
+ auto& manager = CPythonCharacterManager::Instance();
+ const auto saved = player.AutoHunt_GetConfig();
+ TPixelPosition savedMain; main->NEW_GetPixelPosition(&savedMain);
+ auto* existing = manager.GetInstancePtr(FakeLoginServer::kMobVID);
+ TPixelPosition savedMob; existing->NEW_GetPixelPosition(&savedMob);
+ TPixelPosition origin = savedMob; origin.x += 1000;
+ main->NEW_Stop(); main->NEW_SetPixelPosition(origin);
+ CHECK(!main->IsInSafe());
+ TPixelPosition far = origin; far.x += 20000;
+ existing->NEW_Stop(); existing->NEW_SetPixelPosition(far);
+ auto config = saved; config.iGatherCount = 0; config.iPickup = CPythonPlayer::AUTO_HUNT_PICKUP_ALL;
+ config.bAttackStone = false; config.vecSkillIndices.clear();
+ player.AutoHunt_SetEnabled(false); player.AutoHunt_SetConfig(config);
+ const DWORD itemVID = 0x710010, mobVID = 0x710011;
+ CPythonItem::Instance().CreateItem(itemVID, 1, origin.x + 500, origin.y, origin.z, false);
+ player.AutoHunt_SetEnabled(true);
+ CHECK(pump_until(2, [&] { return player.AutoHunt_GetState() == CPythonPlayer::AUTO_HUNT_LOOT; }));
+ CInstanceBase::SCreateData data = {};
+ data.m_bType = CActorInstance::TYPE_ENEMY; data.m_dwRace = existing->GetRace();
+ data.m_dwVID = mobVID; data.m_dwMovSpd = 100; data.m_dwAtkSpd = 100;
+ TPixelPosition here; main->NEW_GetPixelPosition(&here);
+ data.m_lPosX = LONG(here.x + 80); data.m_lPosY = LONG(here.y);
+ data.m_stName = "LootInterruptFixture";
+ auto* mob = manager.CreateInstance(data);
+ CHECK(mob);
+ CHECK(pump_until(2, [&] { return player.AutoHunt_GetState() == CPythonPlayer::AUTO_HUNT_ENGAGE && player.GetTargetVID() == mobVID; }));
+ player.AutoHunt_SetEnabled(false);
+ manager.DeleteInstance(mobVID);
+ // Once the threat is gone, loot is eligible again rather than permanently abandoned.
+ main->NEW_Stop(); main->NEW_SetPixelPosition(origin);
+ player.AutoHunt_SetEnabled(true);
+ CHECK(pump_until(2, [&] { return player.AutoHunt_GetState() == CPythonPlayer::AUTO_HUNT_LOOT; }));
+ player.AutoHunt_SetEnabled(false);
+ CPythonItem::Instance().DeleteItem(itemVID);
+ existing->NEW_Stop(); existing->NEW_SetPixelPosition(savedMob);
+ main->NEW_Stop(); main->NEW_SetPixelPosition(savedMain);
+ player.AutoHunt_SetConfig(saved);
+ pump_frames(30);
+}
diff --git a/tests/port/biolog_proto_patch.cpp b/tests/port/biolog_proto_patch.cpp
new file mode 100644
index 00000000..a2336c80
--- /dev/null
+++ b/tests/port/biolog_proto_patch.cpp
@@ -0,0 +1,60 @@
+// PORT: isolated item-table extension. All non-biology records stay byte-identical.
+#include "GameLib/StdAfx.h"
+#include "GameLib/ItemData.h"
+#include "EterBase/lzo.h"
+#include
+#include
+#include
+#include