Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507e687867 | ||
|
|
f266072f67 | ||
|
|
90f213c4cc | ||
|
|
d11d46c469 | ||
|
|
8164c02709 | ||
|
|
84404cf1f1 | ||
|
|
32372c2a73 | ||
|
|
5dcac9cbd4 | ||
|
|
b14fb2ffe9 |
+14
@@ -44,3 +44,17 @@ __pycache__/
|
|||||||
/android/app/build/
|
/android/app/build/
|
||||||
/android/build/
|
/android/build/
|
||||||
/android/local.properties
|
/android/local.properties
|
||||||
|
|
||||||
|
/Client/autohunt.cfg
|
||||||
|
|
||||||
|
# MeshPort private network identity; SDK build output normally lives under /build.
|
||||||
|
/config/meshport.private.toml
|
||||||
|
/third_party/meshport/target/
|
||||||
|
|
||||||
|
# Local build outputs, caches and legacy restructure leftovers
|
||||||
|
/build_*/
|
||||||
|
/.codex/
|
||||||
|
/android-native/
|
||||||
|
/extension/
|
||||||
|
/script/
|
||||||
|
/test/
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ endif()
|
|||||||
add_subdirectory(third_party)
|
add_subdirectory(third_party)
|
||||||
add_subdirectory(src/codepage)
|
add_subdirectory(src/codepage)
|
||||||
if(MT_BUILD_PORT OR MT_BUILD_NATIVE_RENDER)
|
if(MT_BUILD_PORT OR MT_BUILD_NATIVE_RENDER)
|
||||||
|
add_subdirectory(src/meshport)
|
||||||
add_subdirectory(src/port)
|
add_subdirectory(src/port)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
|
|
||||||
需要 CMake、SDL3、Vulkan loader、MoltenVK、glslc(第三方库均已随仓库提供源码,无需子模块):
|
需要 Rust 1.95+、protoc、CMake、SDL3、Vulkan loader、MoltenVK、glslc(第三方库均已随仓库提供源码,无需子模块):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
./scripts/build_meshport.sh macos
|
||||||
cmake -S . -B build/release -DCMAKE_BUILD_TYPE=Release \
|
cmake -S . -B build/release -DCMAKE_BUILD_TYPE=Release \
|
||||||
-DMT_BUILD_NATIVE_RENDER=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
|
-DMT_BUILD_NATIVE_RENDER=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
|
||||||
cmake --build build/release --target mt_native_render -j8
|
cmake --build build/release --target mt_native_render -j8
|
||||||
@@ -14,6 +15,8 @@ cmake --build build/release --target mt_native_render -j8
|
|||||||
--live-client "/path/to/40250/Client" --login-screen --width 1024 --height 768
|
--live-client "/path/to/40250/Client" --login-screen --width 1024 --height 768
|
||||||
```
|
```
|
||||||
|
|
||||||
|
MeshPort SDK 源码在 `third_party/meshport`,无需单独检出 MeshPort 项目。频道查询、登录、注册、地图/公会连接及 UDP 都在公共网络层判断:同网段直连,跨网段组网,使用 DHCP 动态 IP;远端连接验证成功后提示“组网成功”。私有配置与构建说明见 [docs/MESHPORT.md](docs/MESHPORT.md)。
|
||||||
|
|
||||||
详见 [src/host/README.md](src/host/README.md)。
|
详见 [src/host/README.md](src/host/README.md)。
|
||||||
|
|
||||||
## Android
|
## Android
|
||||||
|
|||||||
@@ -74,11 +74,15 @@ if [ ! -f "$OUT/sdl3/lib/libSDL3.so" ] || [ ! -f "$OUT/sdl3/.page16k" ]; then
|
|||||||
touch "$OUT/sdl3/.page16k"
|
touch "$OUT/sdl3/.page16k"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- Embedded application-only networking SDK (source lives in this repository) ---
|
||||||
|
"$REPO/scripts/build_meshport.sh" android
|
||||||
|
|
||||||
# --- libmain.so (src/host + port_platform + embedded Python) and its assets ---
|
# --- libmain.so (src/host + port_platform + embedded Python) and its assets ---
|
||||||
cmake -S . -B "$OUT/app-build" -G "Unix Makefiles" -Wno-deprecated \
|
cmake -S . -B "$OUT/app-build" -G "Unix Makefiles" -Wno-deprecated \
|
||||||
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM="android-$API" "$PAGE_SIZES" \
|
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM="android-$API" "$PAGE_SIZES" \
|
||||||
-DCMAKE_BUILD_TYPE="$CONFIG" -DMT_BUILD_TOOLS=OFF -DBUILD_TESTING=OFF \
|
-DCMAKE_BUILD_TYPE="$CONFIG" -DMT_BUILD_TOOLS=OFF -DBUILD_TESTING=OFF \
|
||||||
-DMT_BUILD_NATIVE_RENDER=ON -DSDL3_DIR="$OUT/sdl3/lib/cmake/SDL3" \
|
-DMT_BUILD_NATIVE_RENDER=ON -DSDL3_DIR="$OUT/sdl3/lib/cmake/SDL3" \
|
||||||
|
-DMT_MESHPORT_LIBRARY="${MT_MESHPORT_LIBRARY:-$REPO/build/meshport-runtime/aarch64-linux-android/release/libmetin2_meshport.a}" \
|
||||||
-DCMAKE_FIND_ROOT_PATH="$OUT/sdl3" -DGLSLC="$GLSLC" >/dev/null
|
-DCMAKE_FIND_ROOT_PATH="$OUT/sdl3" -DGLSLC="$GLSLC" >/dev/null
|
||||||
cmake --build "$OUT/app-build" --target main mt_native_android_assets -j "$JOBS"
|
cmake --build "$OUT/app-build" --target main mt_native_android_assets -j "$JOBS"
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,13 @@
|
|||||||
服务端 `item_proto.txt` 里改过的物品使用限制(每行 VNUM、限制类型 0、值 0、限制类型 1、值 1;0 = 无,1 = 等级),
|
服务端 `item_proto.txt` 里改过的物品使用限制(每行 VNUM、限制类型 0、值 0、限制类型 1、值 1;0 = 无,1 = 等级),
|
||||||
由 `locale_proto_patch` 写进客户端 `item_proto`,提示框显示的等级要求与服务端一致。其余字段仍逐字节校验不变。
|
由 `locale_proto_patch` 写进客户端 `item_proto`,提示框显示的等级要求与服务端一致。其余字段仍逐字节校验不变。
|
||||||
|
|
||||||
|
## 物品占格与标志(item_shapes_server.tsv)
|
||||||
|
|
||||||
|
服务端给了新用途、改过占格或标志的物品(每行 VNUM、size、flags、anti flags,数值),由 `locale_proto_patch`
|
||||||
|
写进客户端 `item_proto`,背包占格和堆叠与服务端一致;只允许这几条记录的这三个字段变化。目前是 VIP 会员卡
|
||||||
|
38001/38002(`tools/server/vip-card`):40250 原为 size 0,背包里不显示也不能移动。
|
||||||
|
该表跟在 `item_stones_server.tsv` 后面传入(位置参数),所以需要石头表也存在。
|
||||||
|
|
||||||
## 物品说明(itemdesc_translate_zh.tsv)
|
## 物品说明(itemdesc_translate_zh.tsv)
|
||||||
|
|
||||||
40250 `itemdesc.txt` 的说明列(第 3 列)和摘要列(第 4 列,“Potion production”“Research”)按英文原文整句翻译,
|
40250 `itemdesc.txt` 的说明列(第 3 列)和摘要列(第 4 列,“Potion production”“Research”)按英文原文整句翻译,
|
||||||
|
|||||||
@@ -685,8 +685,8 @@ VNUM LOCALE_NAME
|
|||||||
31092 超冷怪的皮肤碎片
|
31092 超冷怪的皮肤碎片
|
||||||
35000 药草+
|
35000 药草+
|
||||||
35001 虎肝+
|
35001 虎肝+
|
||||||
38001 VIP特权
|
38001 VIP会员卡(7天)
|
||||||
38002 抽奖券(今日)
|
38002 VIP会员卡(30天)
|
||||||
38003 抽奖券(明日)
|
38003 抽奖券(明日)
|
||||||
38004 抽奖重抽券
|
38004 抽奖重抽券
|
||||||
38005 高级抽奖转盘券
|
38005 高级抽奖转盘券
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# vnum size flags anti flags: item_proto.txt size/flags changed on our server (tools/server/*), mirrored into the
|
||||||
|
# client proto by locale_proto_patch so the item takes a cell and stacks like on the server.
|
||||||
|
# 38001/38002 VIP 会员卡 (tools/server/vip-card): 40250 ships them size 0, no flags; now size 1,
|
||||||
|
# ITEM_STACKABLE | ITEM_LOG (4 | 8192), ANTI_SELL (256).
|
||||||
|
38001 1 8196 256
|
||||||
|
38002 1 8196 256
|
||||||
|
+2
-1
@@ -41,4 +41,5 @@
|
|||||||
- 50037 剩余 20 个开全部:`done 50037 20 20 done 0`,获得 11 种;
|
- 50037 剩余 20 个开全部:`done 50037 20 20 done 0`,获得 11 种;
|
||||||
- 不按 Ctrl 右键:原单个使用,礼盒正常扣除;
|
- 不按 Ctrl 右键:原单个使用,礼盒正常扣除;
|
||||||
- 日志库只有原路径写的 `SYSTEM`、`TREASURE_EXP`、`TREASURE_GOLD` 行;syserr 没有新错误(只有原有的 `new_quest_lv52` LUA_ERROR)。
|
- 日志库只有原路径写的 `SYSTEM`、`TREASURE_EXP`、`TREASURE_GOLD` 行;syserr 没有新错误(只有原有的 `new_quest_lv52` LUA_ERROR)。
|
||||||
- 未部署 203 正式环境,未做 Android 真机测试;触屏没有 Ctrl,暂无触屏入口。
|
- 2026-10-15 16:43 部署到 203 正式环境(服务端 bc32475),CH1(13000)用同一场景复验通过(50037 开 10 个 / 剩余 20 个、50070 跨 4 格、71036 拒绝、限速、普通右键不变)。
|
||||||
|
- 未做 Android 真机测试;触屏没有 Ctrl,暂无触屏入口。
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
|
|
||||||
2026-10-15 后续批次:阶段 0 的只读能力协商、阶段 2/3 的掉落/来源/宝箱查询、阶段 4 属性面板已实现并在隔离环境测试;未部署 203,待联机与真机验收。范围和缺口见 AVA2-READONLY-FEATURES.md。
|
2026-10-15 后续批次:阶段 0 的只读能力协商、阶段 2/3 的掉落/来源/宝箱查询、阶段 4 属性面板已实现并在隔离环境测试;未部署 203,待联机与真机验收。范围和缺口见 AVA2-READONLY-FEATURES.md。
|
||||||
|
|
||||||
2026-10-15 再续:龙穴/周任务信息与提醒、共享真实掉落开关的活动日历已实现并在隔离环境验证。副本仅首批龙穴;活动仅月光宝箱/圣诞袜,生产未部署,无生产排期。自动洗练/锁定、战令、模型预览、批量开箱仍待实现。见 AVA2-DUNGEON-CALENDAR.md。
|
2026-10-15 再续:龙穴/周任务信息与提醒、共享真实掉落开关的活动日历已实现并在隔离环境验证。副本仅首批龙穴;活动仅月光宝箱/圣诞袜,生产未部署,无生产排期。自动洗练/锁定、战令、模型预览、批量开箱仍待实现(2026-10-16:模型预览已实现,见 FEATURE-MODEL-PREVIEW.md)。见 AVA2-DUNGEON-CALENDAR.md。
|
||||||
|
|
||||||
2026-10-15 自动洗练 v1(阶段 6,不含属性锁定):服务端 mt_switchbot 逐次调用原 UseItem 洗练路径,客户端 mt_switchbot 窗口;隔离环境编译与桩测试通过,macOS 客户端已在 203 测试核心联机验收;未部署 203 正式环境、未做 Android 真机测试。属性锁定仍待存储设计。见 AVA2-SWITCHBOT.md。
|
2026-10-15 自动洗练 v1(阶段 6,不含属性锁定):服务端 mt_switchbot 逐次调用原 UseItem 洗练路径,客户端 mt_switchbot 窗口;隔离环境编译与桩测试通过,macOS 客户端已在 203 测试核心联机验收;未部署 203 正式环境、未做 Android 真机测试。属性锁定仍待存储设计。见 AVA2-SWITCHBOT.md。
|
||||||
|
|
||||||
@@ -127,4 +127,4 @@
|
|||||||
2026-10-15 世界 Boss 追踪(AVA2 uitrack 世界 Boss 页,只读,不含传送):服务端各核心用原 HEADER_GG_RELAY(名字 #mt_boss)共享定时 Boss 刷新点,旧核心忽略;客户端 mt_boss 窗口 + 刷新提醒,查询种类 bosses(能力位 256);已在 203 两个 channel5 测试核心上做跨核心联机验收,正式核心尚未部署。见 AVA2-BOSS.md。
|
2026-10-15 世界 Boss 追踪(AVA2 uitrack 世界 Boss 页,只读,不含传送):服务端各核心用原 HEADER_GG_RELAY(名字 #mt_boss)共享定时 Boss 刷新点,旧核心忽略;客户端 mt_boss 窗口 + 刷新提醒,查询种类 bosses(能力位 256);已在 203 两个 channel5 测试核心上做跨核心联机验收,正式核心尚未部署。见 AVA2-BOSS.md。
|
||||||
2026-10-15 属性锁定(阶段 6 后半,参考 AVA2 ENABLE_LOCK_ATTR):服务端新表 mt_item_attr_lock(按物品 ID 存锁定掩码,各核心启动时自动建表;不改动物品结构、db 缓存和客户端封包),CItem::ChangeAttribute 保留锁定槽位,手动、自动、GM 洗练全部生效;精炼时锁定随属性复制;最多 2 组,加成武器的 0/1 槽算一组;锁定免费。客户端在自动洗练窗口里加 5 个锁定按钮。已在 203 channel5 测试核心联机验收(洗 10 次锁定的 2 条不变,重新登录后仍在);2026-10-15 16:08 部署到 203 正式环境(服务端 dc71593),CH1 复验通过;未做 Android 真机测试。见 AVA2-SWITCHBOT.md 和服务端 FEATURE-ATTR-LOCK.md。
|
2026-10-15 属性锁定(阶段 6 后半,参考 AVA2 ENABLE_LOCK_ATTR):服务端新表 mt_item_attr_lock(按物品 ID 存锁定掩码,各核心启动时自动建表;不改动物品结构、db 缓存和客户端封包),CItem::ChangeAttribute 保留锁定槽位,手动、自动、GM 洗练全部生效;精炼时锁定随属性复制;最多 2 组,加成武器的 0/1 槽算一组;锁定免费。客户端在自动洗练窗口里加 5 个锁定按钮。已在 203 channel5 测试核心联机验收(洗 10 次锁定的 2 条不变,重新登录后仍在);2026-10-15 16:08 部署到 203 正式环境(服务端 dc71593),CH1 复验通过;未做 Android 真机测试。见 AVA2-SWITCHBOT.md 和服务端 FEATURE-ATTR-LOCK.md。
|
||||||
|
|
||||||
2026-10-15 批量开箱(阶段 3 的交互子批,参考 AVA2 ENABLE_MULTI_CHEST /chest_drop):服务端 mt_chest 对同一种礼盒(ITEM_GIFTBOX)逐个调用原 UseItem 开箱路径,可跨格子(203 上多数礼盒不叠加),单次最多 200 个,背包少于 3 格空位即停止;奖励组含刷怪/减速/中毒/吸血的礼盒不允许批量。客户端 Ctrl + 右键礼盒打开数量窗口(1/10/50/全部),结果汇总到聊天框。已在 203 channel5 测试核心联机验收;未部署 203 正式环境,未做 Android 真机测试和触屏入口。见 AVA2-CHEST.md 和服务端 FEATURE-CHEST.md。
|
2026-10-15 批量开箱(阶段 3 的交互子批,参考 AVA2 ENABLE_MULTI_CHEST /chest_drop):服务端 mt_chest 对同一种礼盒(ITEM_GIFTBOX)逐个调用原 UseItem 开箱路径,可跨格子(203 上多数礼盒不叠加),单次最多 200 个,背包少于 3 格空位即停止;奖励组含刷怪/减速/中毒/吸血的礼盒不允许批量。客户端 Ctrl + 右键礼盒打开数量窗口(1/10/50/全部),结果汇总到聊天框。已在 203 channel5 测试核心联机验收;2026-10-15 16:43 部署到 203 正式环境(服务端 bc32475),CH1 复验通过;未做 Android 真机测试和触屏入口。见 AVA2-CHEST.md 和服务端 FEATURE-CHEST.md。
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Model preview
|
||||||
|
|
||||||
|
Ported from AVA2's render-target window (`EterLib/CRenderTarget*`, `GrpRenderTargetTexture.*`, `UserInterface/CPythonRenderTargetModule.cpp`, `root/uirendertarget.py`). The preview shows:
|
||||||
|
- a wearable item on a character of every race that may wear it;
|
||||||
|
- a monster, with its motions.
|
||||||
|
|
||||||
|
It is client only; the server is not involved.
|
||||||
|
|
||||||
|
## Entry points
|
||||||
|
|
||||||
|
| Where | How |
|
||||||
|
|---|---|
|
||||||
|
| Inventory | hold X and left-click the item |
|
||||||
|
| NPC shop | hold X and left-click the item |
|
||||||
|
| Wiki detail | the "模型预览" line, on weapon / armor / costume rows that have a model, and on every monster |
|
||||||
|
|
||||||
|
Without X the original click path runs unchanged. The inventory wrapper chains onto `mt_mobile_inventory`'s, because `mt_preview` is installed after it. There is no touch entry other than the wiki.
|
||||||
|
|
||||||
|
## Window (`ScriptLib/ModelPreviewScript.inc`, module `mt_preview`)
|
||||||
|
|
||||||
|
### Item mode
|
||||||
|
- `ItemSpec(vnum)` accepts:
|
||||||
|
- a weapon other than an arrow → `SetWeapon`;
|
||||||
|
- a body armor or a body costume → `SetArmor`;
|
||||||
|
- a hair costume → `SetHair`, which reads value3.
|
||||||
|
- The races are those not excluded by `ITEM_ANTIFLAG_{MALE,FEMALE,WARRIOR,ASSASSIN,SURA,SHAMAN}`, ordered warrior, assassin, sura, shaman, with male first. The window starts on the player's own race if it is allowed.
|
||||||
|
- `<` / `>` step through the allowed races.
|
||||||
|
|
||||||
|
### Mob mode
|
||||||
|
- `SelectModel(vnum)`.
|
||||||
|
- Motion buttons: 待机 1, 攻击 13, 受击 5, 死亡 11, 技能1–4 33–36 (`InterceptOnceMotion`).
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
- Dragging with the left button turns the camera: absolute yaw/pitch from the press point, pitch clamped to ±60°.
|
||||||
|
- 放大 / 缩小 change the zoom in 5 % steps, clamped to −8..+12. Windows have no wheel event.
|
||||||
|
- 自动旋转 spins the model.
|
||||||
|
- 复位 rebuilds the model, which also resets the camera.
|
||||||
|
|
||||||
|
### Lifetime
|
||||||
|
- Render target index 1 is created at 300×330 when the window opens.
|
||||||
|
- It is removed on close or Esc, and on every re-open before it is created again. `GameWindow.Close` also calls `renderTarget.Destroy()`.
|
||||||
|
- The host frees the GPU image 120 frames after its last use.
|
||||||
|
|
||||||
|
## Native side
|
||||||
|
|
||||||
|
- `renderTarget` Python module (`port/UserInterface/PythonRenderTargetModule.cpp`) → `CRenderTargetManager` → `CRenderTarget`, which owns a `CInstanceBase` and a `CGraphicRenderTargetTexture`.
|
||||||
|
- `CPythonApplication` calls `DeformModels` / `UpdateModels` / `RenderModels` around the character manager.
|
||||||
|
|
||||||
|
### Port differences from AVA2
|
||||||
|
- Player races (0..7) are created as `TYPE_PC`. 40250 reserves the armor/weapon/hair parts only for PCs, so AVA2's `TYPE_NPC` crashed on the first `ChangeArmor`.
|
||||||
|
- The model sits at the world origin, where culling hides it. Instead of AVA2's `SetAlwaysRender`, it is shown again before `Deform` and `Render`. Its text tail is detached.
|
||||||
|
- The background quad is drawn inside the render target, in `RenderModel`. `RenderBackgrounds` is a no-op.
|
||||||
|
- Wiki affects are not ported.
|
||||||
|
- The texture has 1 mip level.
|
||||||
|
|
||||||
|
### Host (Vulkan)
|
||||||
|
- A colour render target is `D3DFMT_X8R8G8B8` / `A8R8G8B8` with `D3DUSAGE_RENDERTARGET`, named `rt:<id>:<w>x<h>:c`.
|
||||||
|
- It gets its own RGBA8 offscreen pass (pipeline key bits 29–30: 0 none, 1 shadow, 2 colour) and is cleared to black.
|
||||||
|
- Shadow targets keep the previous path.
|
||||||
|
- Side effect: SnowEnvironment's blur targets now take the colour path. Snow blur is off by default.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/wiki/test_model_preview.py` covers:
|
||||||
|
- `ItemSpec` races and slots;
|
||||||
|
- race stepping and fallback;
|
||||||
|
- motions and zoom clamps;
|
||||||
|
- drag;
|
||||||
|
- reset;
|
||||||
|
- the view drawing the target;
|
||||||
|
- no live target after close, Esc, or 5 re-opens;
|
||||||
|
- the inventory and shop wrappers;
|
||||||
|
- wiki links and module order.
|
||||||
|
- ctest 49/49.
|
||||||
|
- Live, channel5 13040, macOS client, 2026-10-16:
|
||||||
|
- armor 11299 on a warrior;
|
||||||
|
- sword 19 after a 90°/20° turn;
|
||||||
|
- wild dog 101 with the attack motion;
|
||||||
|
- boss 691 zoomed in with spin.
|
||||||
|
- The main scene was unchanged.
|
||||||
|
- 120 open/close cycles (30 paced, plus 60 in one frame) did not grow RSS: 668 MB before, 593 MB after.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 客户端内置 MeshPort:同网段直连,跨网段统一组网
|
||||||
|
|
||||||
|
SDK 源码在 `third_party/meshport/`;客户端的后台运行时在 `src/meshport/runtime/`,TCP/UDP 平台适配在 `src/meshport/transport.cpp`。不依赖相邻的 MeshPort 工程或另行安装的 MeshPort 应用。
|
||||||
|
|
||||||
|
## 统一路径
|
||||||
|
|
||||||
|
组网按每一个目标 IPv4 地址决定,位于原生网络层,不依赖登录窗口或按钮。首次跨网段请求(包括登录前频道查询、先注册、先发送 UDP、命令行自动登录)都可触发后台启动。
|
||||||
|
|
||||||
|
| 请求 | 接入点 |
|
||||||
|
|---|---|
|
||||||
|
| 登录前频道状态查询 | `CServerStateChecker` → `CNetworkStream`;当前实现是 TCP |
|
||||||
|
| 账号认证、游戏连接 | `CAccountConnector`、`CPythonNetworkStream` → `CNetworkStream` |
|
||||||
|
| 换地图、换线、断线后的新连接 | 每次 `CNetworkStream::Connect` 重新判断目标地址 |
|
||||||
|
| 公会图标/标志上传和下载 | `CGuildMarkUploader`、`CGuildMarkDownloader` → `CNetworkStream` |
|
||||||
|
| 注册账号 HTTP | `PythonAccountRegister.cpp` 的工作线程使用相同 TCP 连接与 SOCKS 协商 |
|
||||||
|
| 通用 UDP | `CNetworkDatagram`、`CNetDatagramSender`、`CNetDatagramReceiver` → 统一 SOCKS5 UDP ASSOCIATE |
|
||||||
|
|
||||||
|
百科、洗练、拾取、战令等服务端命令复用游戏连接,自动跟随该连接的路径。域名沿用系统 IPv4 解析,解析后的地址进入相同判断;纯数字开头的域名不会再被误当成 IP。游戏现有封包、加密、原始数据内容保持不变。
|
||||||
|
|
||||||
|
## 路由与生命周期
|
||||||
|
|
||||||
|
- 使用本机已启用网卡的实际 IPv4 地址、子网掩码判断,不假定 `/24`。排除回环、点对点及 TUN/TAP 网卡;回环目标直接连接,便于本地测试。
|
||||||
|
- 同网段直接使用系统 TCP/UDP,不为了本地请求启动 MeshPort。一个客户端可以同时访问同网段与跨网段目标。
|
||||||
|
- 跨网段只使用 MeshPort;没有配置、引导连接失败、路由缺失或 SOCKS 被拒绝时返回失败,**不退回系统直连**。
|
||||||
|
- 首次需要时才后台启动 SDK。复用保存配置中的网络身份、密钥、引导节点及传输选项,强制应用模式、`dhcp=true`、清空固定 IPv4 和监听端口。
|
||||||
|
- 等待动态 IP 和 SDK 接受的公告子网就绪。引擎启动、发现节点或仅获取 IP 均不算连接成功;TCP 的远端 SOCKS 建连成功或收到有效 UDP 回包后才提示“组网成功”,约 6 秒。
|
||||||
|
- 同一 SDK 由全部请求共享,多个请求不会重复启动,也不会因另一次登录停止现有连接。运行期间刷新健康状态和子网,后续新目标、重连均重新检查路由。
|
||||||
|
- 地址/路由准备最多 60 秒,SOCKS 建连最多 10 秒;原生 TCP 给整个准备流程最多 75 秒。UDP 发送队列有数量、字节和时限上限,防止断网时无限积压。
|
||||||
|
- UDP 保留原始来源 IP/端口、数据报边界及 `MSG_PEEK`,直连和组网回包可由同一接收 API 读取。TCP 控制连接、UDP relay 与原始 socket 一起回收。
|
||||||
|
- 关闭注册窗口会取消路由等待和 HTTP I/O,已发送到服务器的请求无法撤回,不做自动重发。退出时先取消并等待注册线程,再停止 SDK;关闭普通连接会移除其待完成协商。登录窗口的取消/退出也只取消尚在准备的账号及游戏连接,不中断独立频道查询或已建立的连接。
|
||||||
|
- 网卡变化后,新连接和每次发送都重新判断;排队的 UDP 若变为同网段,则改走直连。已经建立的 TCP 会话不能迁移到另一条路径:断线后沿原客户端流程返回登录/建立新连接,新连接重新判断;没有新增自动登录或自动重放业务请求。
|
||||||
|
|
||||||
|
使用应用内桥接,不创建系统 VPN、不安装 OS 路由。MeshPort 自己的引导/打洞连接仍使用系统网络,避免组网递归。
|
||||||
|
|
||||||
|
## SDK 的 DHCP 子网修复
|
||||||
|
|
||||||
|
原 SDK 的公告子网过滤器捕获 `TomlConfig.get_ipv4()`。DHCP 启动时它为空,动态地址分配后也不会更新,造成“有 IP、有节点,但无法接受服务器网段”。本地修复从运行中的节点快照更新过滤器,再投影接受的子网;变更记录在 `third_party/meshport/UPSTREAM.md`。
|
||||||
|
|
||||||
|
## 私有配置
|
||||||
|
|
||||||
|
当前开发机默认读取 `~/Library/Application Support/MeshPort/network-profile.toml`。其他电脑可将同一网络的配置放入忽略规则覆盖的 `config/meshport.private.toml`,或设置 CMake 参数:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake -S . -B build/release -DMT_BUILD_NATIVE_RENDER=ON \
|
||||||
|
-DMT_MESHPORT_CONFIG="/path/to/network-profile.toml"
|
||||||
|
```
|
||||||
|
|
||||||
|
配置在构建时读入生成头文件;开发运行时可用环境变量 `MT_MESHPORT_CONFIG` 覆盖。私有配置不进入 Git;包含配置的 APK/可执行文件应按私有客户端分发。错误提示不输出密钥。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
需要 Rust 1.95+(`cargo` 在 PATH 内)和 protoc(在 PATH 内或通过 `PROTOC` 指定)。依赖固定在 Cargo.lock,首次构建需要下载注册表依赖。编译产物默认放在 `build/meshport-runtime`;运行时及其 SDK 作为一份静态库链接,不依赖动态 MeshPort 库。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/build_meshport.sh macos
|
||||||
|
cmake -S . -B build/release -DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DMT_BUILD_NATIVE_RENDER=ON -DCMAKE_PREFIX_PATH=/opt/homebrew
|
||||||
|
cmake --build build/release --target mt_native_render -j8
|
||||||
|
```
|
||||||
|
|
||||||
|
Android arm64:`./android/build.sh Release --no-deploy` 会先编译运行时和 SDK,再构建客户端和 APK。独立构建可执行 `./scripts/build_meshport.sh android`;最低 API 24,链接对齐 16 KB 页面。
|
||||||
|
|
||||||
|
`MT_MESHPORT_LIBRARY` 可覆盖静态库路径,`MT_MESHPORT_ROOT` 可覆盖 CMake 中 SDK 头文件路径。组网关闭的专项测试可设置 `-DMT_ENABLE_MESHPORT=OFF`。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
本地真实 SDK 网关将公告网段映射到回环 TCP/UDP 测试服务,测试安装零条 OS 路由,无需管理员权限。构建测试网关时使用与运行时相同的 Cargo target 目录:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CARGO_TARGET_DIR="$PWD/build/meshport-runtime" cargo build \
|
||||||
|
--manifest-path src/meshport/runtime/Cargo.toml \
|
||||||
|
--example gateway_fixture --release --locked
|
||||||
|
cmake -S . -B build/native -DBUILD_TESTING=ON
|
||||||
|
cmake --build build/native --target meshport_transport_test meshport_network_paths_test -j4
|
||||||
|
ctest --test-dir build/native -R '^meshport\.' --output-on-failure
|
||||||
|
python3 tests/meshport/test_network_paths.py \
|
||||||
|
build/native/src/meshport/meshport_network_paths_test --client
|
||||||
|
```
|
||||||
|
|
||||||
|
测试包括:实际子网掩码、同网段零组网启动、UDP 首次请求触发 DHCP、真实公告子网 TCP/UDP 转发、UDP 来源与 peek、并发账号/游戏类型连接、HTTP、缺失路由不直连、取消协商、SOCKS 分片/拒绝、失败提示与成功提示生命周期。`--client` 还启动真实 Python 2.7 原生客户端,调用实际注册接口验证取消后重新注册。
|
||||||
|
|
||||||
|
跨公网到正式服务器的完整游戏登录、Android 真机切换 Wi-Fi/移动网络需要相应网络与设备验收;本地回环网关测试不能代替这些现场验证。
|
||||||
|
|
||||||
|
本次本机验证记录(2026-10-16):
|
||||||
|
|
||||||
|
- macOS 原生客户端和 Android arm64 APK 构建成功。
|
||||||
|
- `meshport.transport`、`meshport.login`、`meshport.network_paths`、`meshport.dhcp`、`port.net` 共 5 组通过:`build/meshport-final-tests.log`。
|
||||||
|
- 真实 Python 2.7 客户端验证了登录前路由、取消待完成登录、实际 HTTP 注册、取消后重新注册:`build/meshport-path-client-tests.log`、`build/meshport-registration-runtime.log`。
|
||||||
|
- 完整 `port.login_flow` 的账号/加密登录及前段网络检查通过,后段 NPC 商店/任务/仓库点击检查仍有 27 项失败。用关闭组网模块的对照程序复验,失败数量和源码行号完全相同;此项不记为整体通过。日志:`build/meshport-regression-tests.log`、`build/meshport-baseline/test.log`。
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build only MeshPort's application-mode SDK; no VPN or internet proxy engine.
|
||||||
|
set -euo pipefail
|
||||||
|
repo="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
mesh="${MT_MESHPORT_ROOT:-$repo/third_party/meshport}"
|
||||||
|
platform="${1:-macos}"
|
||||||
|
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$repo/build/meshport-runtime}"
|
||||||
|
command -v cargo >/dev/null || { echo "Rust 1.95+ is required (cargo not found)" >&2; exit 1; }
|
||||||
|
cd "$mesh"
|
||||||
|
if [ "$platform" = android ]; then
|
||||||
|
sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}"
|
||||||
|
ndk="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}"
|
||||||
|
if [ -z "$ndk" ]; then ndk="$sdk/ndk/$(ls "$sdk/ndk" | sort -V | tail -1)"; fi
|
||||||
|
tools="$ndk/toolchains/llvm/prebuilt/darwin-x86_64/bin"
|
||||||
|
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$tools/aarch64-linux-android24-clang"
|
||||||
|
export CC_aarch64_linux_android="$CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER"
|
||||||
|
export AR_aarch64_linux_android="$tools/llvm-ar"
|
||||||
|
export CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS='-C link-arg=-Wl,-z,max-page-size=16384'
|
||||||
|
exec cargo build --manifest-path "$repo/src/meshport/runtime/Cargo.toml" --target aarch64-linux-android --release --locked
|
||||||
|
elif [ "$platform" = macos ]; then
|
||||||
|
exec cargo build --manifest-path "$repo/src/meshport/runtime/Cargo.toml" --release --locked
|
||||||
|
fi
|
||||||
|
printf 'usage: %s [macos|android]\n' "$0" >&2
|
||||||
|
exit 2
|
||||||
@@ -350,11 +350,15 @@ def build(en: Path, zh: Path, output: Path, client_base: Path,
|
|||||||
limits = names_dir / "item_limits_server.tsv"
|
limits = names_dir / "item_limits_server.tsv"
|
||||||
if (names_dir / "item_stones_server.tsv").is_file() and not limits.is_file():
|
if (names_dir / "item_stones_server.tsv").is_file() and not limits.is_file():
|
||||||
raise ValueError("stone overlay requires the item limits table argument")
|
raise ValueError("stone overlay requires the item limits table argument")
|
||||||
|
if (names_dir / "item_shapes_server.tsv").is_file() and not (names_dir / "item_stones_server.tsv").is_file():
|
||||||
|
raise ValueError("item shapes require the stone overlay argument")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"),
|
[str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"),
|
||||||
str(item_names), str(mob_names), str(locale_dir / "item_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 []),
|
+ ([str(names_dir / "item_stones_server.tsv")] if (names_dir / "item_stones_server.tsv").is_file() else [])
|
||||||
|
# item_shapes_server.tsv follows the stone overlay (positional): size and flags of new-use items.
|
||||||
|
+ ([str(names_dir / "item_shapes_server.tsv")] if (names_dir / "item_shapes_server.tsv").is_file() else []),
|
||||||
check=True, capture_output=True, text=True,
|
check=True, capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
report["proto"] = {
|
report["proto"] = {
|
||||||
@@ -393,7 +397,10 @@ def build_biolog(output: Path, pack_tool: Path):
|
|||||||
root = REPO / "tools/server/biolog"
|
root = REPO / "tools/server/biolog"
|
||||||
ava = REPO.parent / "AVA2"
|
ava = REPO.parent / "AVA2"
|
||||||
archive = ava / "2. Backup SF+SRC and SQL/FILES+SRC.tgz"
|
archive = ava / "2. Backup SF+SRC and SQL/FILES+SRC.tgz"
|
||||||
if not archive.is_file():
|
extracted = ava / "TEST/2. Backup SF+SRC and SQL/home/srv1/db/item_proto.txt"
|
||||||
|
if not archive.is_file() and extracted.is_file():
|
||||||
|
ava = ava / "TEST" # the reference unpacked under AVA2/TEST
|
||||||
|
elif not archive.is_file():
|
||||||
raise ValueError("AVA2 biology requires the local FILES+SRC.tgz reference")
|
raise ValueError("AVA2 biology requires the local FILES+SRC.tgz reference")
|
||||||
proto_tool = tool("biolog_proto_patch")
|
proto_tool = tool("biolog_proto_patch")
|
||||||
if not proto_tool.is_file():
|
if not proto_tool.is_file():
|
||||||
@@ -401,11 +408,14 @@ def build_biolog(output: Path, pack_tool: Path):
|
|||||||
stage = output.parent / "biolog"
|
stage = output.parent / "biolog"
|
||||||
stage.mkdir(parents=True, exist_ok=True)
|
stage.mkdir(parents=True, exist_ok=True)
|
||||||
proto = stage / "ava2-item-proto.txt"
|
proto = stage / "ava2-item-proto.txt"
|
||||||
with tarfile.open(archive, "r:gz") as source:
|
if not archive.is_file():
|
||||||
member = source.extractfile("home/srv1/db/item_proto.txt")
|
shutil.copyfile(extracted, proto)
|
||||||
if member is None:
|
else:
|
||||||
raise ValueError("AVA2 item table missing")
|
with tarfile.open(archive, "r:gz") as source:
|
||||||
proto.write_bytes(member.read())
|
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")
|
spec = importlib.util.spec_from_file_location("biolog_assets", root / "make_assets.py")
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
@@ -421,6 +431,38 @@ def build_biolog(output: Path, pack_tool: Path):
|
|||||||
return {"stages": 13, "item_templates": 29, "pack": "biolog", "first_priority": True}
|
return {"stages": 13, "item_templates": 29, "pack": "biolog", "first_priority": True}
|
||||||
|
|
||||||
|
|
||||||
|
def build_ava2_items(output: Path, pack_tool: Path):
|
||||||
|
"""AVA2 drop items missing on the server, layered over the biolog item table (see tools/server/ava2-items)."""
|
||||||
|
import importlib.util
|
||||||
|
root = REPO / "tools/server/ava2-items"
|
||||||
|
ava = REPO.parent / "AVA2"
|
||||||
|
if not (ava / "1. Client DEV/pack_maker").is_dir():
|
||||||
|
ava = ava / "TEST"
|
||||||
|
reader = REPO.parent / "metin2-server/src/server/db/src/ProtoReader.cpp"
|
||||||
|
if not (ava / "1. Client DEV/pack_maker").is_dir() or not reader.is_file():
|
||||||
|
raise ValueError("AVA2 items need ../AVA2 (pack_maker + FILES+SRC.tgz or server db) and ../metin2-server")
|
||||||
|
proto_tool = tool("item_proto_patch")
|
||||||
|
if not proto_tool.is_file():
|
||||||
|
raise ValueError("build item_proto_patch before generating the Chinese client")
|
||||||
|
stage = output.parent / "ava2-items"
|
||||||
|
spec = importlib.util.spec_from_file_location("ava2_items", root / "make_items.py")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
# biolog's pack carries the current locale/zh item tables; this pack must replace them, so it goes first.
|
||||||
|
report = module.prepare(ava, reader, output.parent / "biolog/assets/locale/zh", stage,
|
||||||
|
proto_tool.resolve(), pack_tool.resolve(), wrap_description)
|
||||||
|
for extension in ("eix", "epk"):
|
||||||
|
shutil.copy2(stage / ("ava2items." + extension), output / "pack" / ("ava2items." + extension))
|
||||||
|
index = output / "pack/Index"
|
||||||
|
lines = index.read_bytes().replace(b"\r\n", b"\n").splitlines()
|
||||||
|
if b"ava2items" in lines:
|
||||||
|
del lines[lines.index(b"ava2items") - 1:lines.index(b"ava2items") + 1]
|
||||||
|
at = lines.index(b"biolog") - 1
|
||||||
|
lines[at:at] = [b"*", b"ava2items"]
|
||||||
|
index.write_bytes(b"\r\n".join(lines) + b"\r\n")
|
||||||
|
return {"items": len(report["kept"]) + len(report["locked"]), "kept": len(report["kept"]),
|
||||||
|
"locked": len(report["locked"]), "assets": report["assets"], "pack": "ava2items"}
|
||||||
|
|
||||||
|
|
||||||
def build_wiki(output: Path, pack_tool: Path):
|
def build_wiki(output: Path, pack_tool: Path):
|
||||||
"""A versioned public server catalogue; original script and proto packs stay intact."""
|
"""A versioned public server catalogue; original script and proto packs stay intact."""
|
||||||
@@ -462,6 +504,7 @@ def main():
|
|||||||
args.proto_tool, args.name_tables, args.pack_tool)
|
args.proto_tool, args.name_tables, args.pack_tool)
|
||||||
report = json.loads(report_path.read_text())
|
report = json.loads(report_path.read_text())
|
||||||
report["biolog"] = build_biolog(args.output, args.pack_tool)
|
report["biolog"] = build_biolog(args.output, args.pack_tool)
|
||||||
|
report["ava2_items"] = build_ava2_items(args.output, args.pack_tool)
|
||||||
report["wiki"] = build_wiki(args.output, args.pack_tool)
|
report["wiki"] = build_wiki(args.output, args.pack_tool)
|
||||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
|
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"Built {args.output / 'pack/locale_zh.eix'} (.epk), registered in pack/Index")
|
||||||
|
|||||||
+10
-8
@@ -173,7 +173,7 @@ void VulkanWindow::prepare_draws(const std::vector<Render3DDraw>& draws, const T
|
|||||||
RenderTarget* target = nullptr;
|
RenderTarget* target = nullptr;
|
||||||
if (draw.render_target) {
|
if (draw.render_target) {
|
||||||
target = get_render_target(
|
target = get_render_target(
|
||||||
Render3DRenderTargetName(draw.render_target, draw.target_width, draw.target_height));
|
Render3DRenderTargetName(draw.render_target, draw.target_width, draw.target_height, draw.target_color));
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
}
|
}
|
||||||
if (draw.clear_flags && target) {
|
if (draw.clear_flags && target) {
|
||||||
@@ -257,7 +257,8 @@ void VulkanWindow::prepare_draws(const std::vector<Render3DDraw>& draws, const T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const auto pipeline = get_pipeline(cull, depth, blend, draw.lines,
|
const auto pipeline = get_pipeline(cull, depth, blend, draw.lines,
|
||||||
static_cast<std::uint8_t>(draw.z_func), target != nullptr, &draw);
|
static_cast<std::uint8_t>(draw.z_func),
|
||||||
|
std::uint8_t(target ? (target->color ? 2 : 1) : 0), &draw);
|
||||||
// D3DTOP_DISABLE (1) on stage 0 or 1 ends the cascade before stage 1 samples.
|
// D3DTOP_DISABLE (1) on stage 0 or 1 ends the cascade before stage 1 samples.
|
||||||
const bool bind_tex1 = !draw.texture1.empty() && draw.color_op[0] > 1 && draw.color_op[1] > 1;
|
const bool bind_tex1 = !draw.texture1.empty() && draw.color_op[0] > 1 && draw.color_op[1] > 1;
|
||||||
const auto descriptor = get_texture_descriptor(
|
const auto descriptor = get_texture_descriptor(
|
||||||
@@ -265,8 +266,9 @@ void VulkanWindow::prepare_draws(const std::vector<Render3DDraw>& draws, const T
|
|||||||
|
|
||||||
auto constants = make_push_constants(draw, skin_offset_encoded);
|
auto constants = make_push_constants(draw, skin_offset_encoded);
|
||||||
if (draw.pretransformed) {
|
if (draw.pretransformed) {
|
||||||
const float screen_width = float(logical_width());
|
// PORT: a pretransformed draw into a render target is in the target's pixels.
|
||||||
const float screen_height = float(logical_height());
|
const float screen_width = target ? float(target->width) : float(logical_width());
|
||||||
|
const float screen_height = target ? float(target->height) : float(logical_height());
|
||||||
// Screen pixels (y down) to D3D NDC (y up); the shader's Y flip then maps to Vulkan.
|
// Screen pixels (y down) to D3D NDC (y up); the shader's Y flip then maps to Vulkan.
|
||||||
constants.mvp = {2.0f / screen_width, 0, 0, 0,
|
constants.mvp = {2.0f / screen_width, 0, 0, 0,
|
||||||
0, -2.0f / screen_height, 0, 0,
|
0, -2.0f / screen_height, 0, 0,
|
||||||
@@ -448,7 +450,7 @@ void VulkanWindow::record_passes(std::uint32_t image_index, const FrameDraws& fr
|
|||||||
for (std::size_t i = 0; i < frame.offscreen_draws.size();) {
|
for (std::size_t i = 0; i < frame.offscreen_draws.size();) {
|
||||||
RenderTarget* target = frame.offscreen_draws[i].target;
|
RenderTarget* target = frame.offscreen_draws[i].target;
|
||||||
VkRenderPassBeginInfo offscreen_begin{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO};
|
VkRenderPassBeginInfo offscreen_begin{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO};
|
||||||
offscreen_begin.renderPass = offscreen_pass_;
|
offscreen_begin.renderPass = target->color ? offscreen_color_pass_ : offscreen_pass_;
|
||||||
offscreen_begin.framebuffer = target->framebuffer;
|
offscreen_begin.framebuffer = target->framebuffer;
|
||||||
offscreen_begin.renderArea.extent = {target->width, target->height};
|
offscreen_begin.renderArea.extent = {target->width, target->height};
|
||||||
vkCmdBeginRenderPass(f_->command, &offscreen_begin, VK_SUBPASS_CONTENTS_INLINE);
|
vkCmdBeginRenderPass(f_->command, &offscreen_begin, VK_SUBPASS_CONTENTS_INLINE);
|
||||||
@@ -559,10 +561,10 @@ VulkanWindow::Readback VulkanWindow::record_readbacks(std::uint32_t image_index)
|
|||||||
const char* dump_rt = std::getenv("MT_DUMP_RT");
|
const char* dump_rt = std::getenv("MT_DUMP_RT");
|
||||||
if (readback.bmp && dump_rt && *dump_rt) {
|
if (readback.bmp && dump_rt && *dump_rt) {
|
||||||
readback.dump_rt = dump_rt;
|
readback.dump_rt = dump_rt;
|
||||||
const std::uint32_t texel = offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4;
|
|
||||||
for (auto& entry : render_targets_) {
|
for (auto& entry : render_targets_) {
|
||||||
RenderTarget& rt = entry.second;
|
RenderTarget& rt = entry.second;
|
||||||
Readback::RtDump dump{VK_NULL_HANDLE, VK_NULL_HANDLE, rt.width, rt.height};
|
const std::uint32_t texel = !rt.color && offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4;
|
||||||
|
Readback::RtDump dump{VK_NULL_HANDLE, VK_NULL_HANDLE, rt.width, rt.height, texel};
|
||||||
VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
||||||
info.size = VkDeviceSize(rt.width) * rt.height * texel; info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
|
info.size = VkDeviceSize(rt.width) * rt.height * texel; info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
|
||||||
check(vkCreateBuffer(device_, &info, nullptr, &dump.buffer), "vkCreateBuffer rt dump");
|
check(vkCreateBuffer(device_, &info, nullptr, &dump.buffer), "vkCreateBuffer rt dump");
|
||||||
@@ -615,9 +617,9 @@ void VulkanWindow::finish_readbacks(Readback& readback) {
|
|||||||
for (auto& sink : readback.sinks) sink(bgra.data(), w, h);
|
for (auto& sink : readback.sinks) sink(bgra.data(), w, h);
|
||||||
}
|
}
|
||||||
vkUnmapMemory(device_, readback.memory);
|
vkUnmapMemory(device_, readback.memory);
|
||||||
const std::uint32_t texel = offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4;
|
|
||||||
for (std::size_t n = 0; n < readback.rt_dumps.size(); ++n) {
|
for (std::size_t n = 0; n < readback.rt_dumps.size(); ++n) {
|
||||||
const auto& dump = readback.rt_dumps[n];
|
const auto& dump = readback.rt_dumps[n];
|
||||||
|
const std::uint32_t texel = dump.texel;
|
||||||
void* data = nullptr;
|
void* data = nullptr;
|
||||||
check(vkMapMemory(device_, dump.memory, 0, VK_WHOLE_SIZE, 0, &data), "vkMapMemory rt dump");
|
check(vkMapMemory(device_, dump.memory, 0, VK_WHOLE_SIZE, 0, &data), "vkMapMemory rt dump");
|
||||||
const auto* bytes = static_cast<const std::uint8_t*>(data);
|
const auto* bytes = static_cast<const std::uint8_t*>(data);
|
||||||
|
|||||||
@@ -104,8 +104,13 @@ void VulkanWindow::create_offscreen_pass() {
|
|||||||
VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT;
|
VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT;
|
||||||
offscreen_format_ = (properties.optimalTilingFeatures & wanted) == wanted
|
offscreen_format_ = (properties.optimalTilingFeatures & wanted) == wanted
|
||||||
? VK_FORMAT_R5G6B5_UNORM_PACK16 : VK_FORMAT_R8G8B8A8_UNORM;
|
? VK_FORMAT_R5G6B5_UNORM_PACK16 : VK_FORMAT_R8G8B8A8_UNORM;
|
||||||
|
offscreen_pass_ = make_offscreen_pass(offscreen_format_);
|
||||||
|
offscreen_color_pass_ = make_offscreen_pass(VK_FORMAT_R8G8B8A8_UNORM);
|
||||||
|
}
|
||||||
|
|
||||||
|
VkRenderPass VulkanWindow::make_offscreen_pass(VkFormat format) {
|
||||||
VkAttachmentDescription attachments[2]{};
|
VkAttachmentDescription attachments[2]{};
|
||||||
attachments[0].format = offscreen_format_; attachments[0].samples = VK_SAMPLE_COUNT_1_BIT;
|
attachments[0].format = format; attachments[0].samples = VK_SAMPLE_COUNT_1_BIT;
|
||||||
attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
||||||
attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
|
attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
|
||||||
attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
|
attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
|
||||||
@@ -146,15 +151,17 @@ void VulkanWindow::create_offscreen_pass() {
|
|||||||
pass_info.attachmentCount = 2; pass_info.pAttachments = attachments;
|
pass_info.attachmentCount = 2; pass_info.pAttachments = attachments;
|
||||||
pass_info.subpassCount = 1; pass_info.pSubpasses = &subpass;
|
pass_info.subpassCount = 1; pass_info.pSubpasses = &subpass;
|
||||||
pass_info.dependencyCount = 2; pass_info.pDependencies = dependencies;
|
pass_info.dependencyCount = 2; pass_info.pDependencies = dependencies;
|
||||||
check(vkCreateRenderPass(device_, &pass_info, nullptr, &offscreen_pass_), "vkCreateRenderPass offscreen");
|
VkRenderPass pass = VK_NULL_HANDLE;
|
||||||
|
check(vkCreateRenderPass(device_, &pass_info, nullptr, &pass), "vkCreateRenderPass offscreen");
|
||||||
|
return pass;
|
||||||
}
|
}
|
||||||
|
|
||||||
VkPipeline VulkanWindow::get_pipeline(std::uint8_t cull, std::uint8_t depth, std::uint8_t blend,
|
VkPipeline VulkanWindow::get_pipeline(std::uint8_t cull, std::uint8_t depth, std::uint8_t blend,
|
||||||
bool lines, std::uint8_t z_func, bool offscreen, const Render3DDraw* draw) {
|
bool lines, std::uint8_t z_func, std::uint8_t offscreen, const Render3DDraw* draw) {
|
||||||
std::array<std::uint32_t, 17> key{};
|
std::array<std::uint32_t, 17> key{};
|
||||||
key[0] = std::uint32_t(cull) | (std::uint32_t(depth) << 8) |
|
key[0] = std::uint32_t(cull) | (std::uint32_t(depth) << 8) |
|
||||||
(std::uint32_t(blend) << 16) | (lines ? (1u << 24) : 0u) |
|
(std::uint32_t(blend) << 16) | (lines ? (1u << 24) : 0u) |
|
||||||
(std::uint32_t(z_func & 0xfu) << 25) | (offscreen ? (1u << 29) : 0u);
|
(std::uint32_t(z_func & 0xfu) << 25) | (std::uint32_t(offscreen & 3u) << 29);
|
||||||
static const bool specialize = std::getenv("MT_GENERIC_SHADERS") == nullptr;
|
static const bool specialize = std::getenv("MT_GENERIC_SHADERS") == nullptr;
|
||||||
key[1] = specialize ? 1u : 0u;
|
key[1] = specialize ? 1u : 0u;
|
||||||
if (specialize && draw) {
|
if (specialize && draw) {
|
||||||
@@ -296,7 +303,7 @@ VkPipeline VulkanWindow::get_pipeline(std::uint8_t cull, std::uint8_t depth, std
|
|||||||
pipeline_info.pMultisampleState = &multisample; pipeline_info.pDepthStencilState = &depth_stencil;
|
pipeline_info.pMultisampleState = &multisample; pipeline_info.pDepthStencilState = &depth_stencil;
|
||||||
pipeline_info.pColorBlendState = &blending;
|
pipeline_info.pColorBlendState = &blending;
|
||||||
pipeline_info.pDynamicState = &dynamic_info;
|
pipeline_info.pDynamicState = &dynamic_info;
|
||||||
pipeline_info.layout = layout_; pipeline_info.renderPass = offscreen ? offscreen_pass_ : pass_;
|
pipeline_info.layout = layout_; pipeline_info.renderPass = offscreen == 2 ? offscreen_color_pass_ : offscreen ? offscreen_pass_ : pass_;
|
||||||
VkPipeline handle = VK_NULL_HANDLE;
|
VkPipeline handle = VK_NULL_HANDLE;
|
||||||
check(vkCreateGraphicsPipelines(device_, VK_NULL_HANDLE, 1, &pipeline_info, nullptr, &handle), "vkCreateGraphicsPipelines");
|
check(vkCreateGraphicsPipelines(device_, VK_NULL_HANDLE, 1, &pipeline_info, nullptr, &handle), "vkCreateGraphicsPipelines");
|
||||||
pipelines_.emplace(key, handle);
|
pipelines_.emplace(key, handle);
|
||||||
|
|||||||
@@ -270,15 +270,23 @@ void VulkanWindow::ensure_ui_capacity(std::size_t command_count) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VulkanWindow::RenderTarget* VulkanWindow::get_render_target(const std::string& name) {
|
VulkanWindow::RenderTarget* VulkanWindow::get_render_target(const std::string& name) {
|
||||||
if (auto it = render_targets_.find(name); it != render_targets_.end()) return &it->second;
|
if (auto it = render_targets_.find(name); it != render_targets_.end()) {
|
||||||
|
it->second.last_used_frame = frame_number_;
|
||||||
|
return &it->second;
|
||||||
|
}
|
||||||
unsigned id = 0, w = 0, h = 0;
|
unsigned id = 0, w = 0, h = 0;
|
||||||
if (std::sscanf(name.c_str(), "rt:%u:%ux%u", &id, &w, &h) != 3 || !w || !h || w > 4096 || h > 4096)
|
int end = 0;
|
||||||
|
if (std::sscanf(name.c_str(), "rt:%u:%ux%u%n", &id, &w, &h, &end) != 3 || !w || !h || w > 4096 || h > 4096)
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
const bool color = name.compare(std::size_t(end), std::string::npos, ":c") == 0;
|
||||||
|
if (!color && name[std::size_t(end)] != '\0') return nullptr;
|
||||||
RenderTarget& target = render_targets_[name];
|
RenderTarget& target = render_targets_[name];
|
||||||
target.width = w;
|
target.width = w;
|
||||||
target.height = h;
|
target.height = h;
|
||||||
|
target.color = color;
|
||||||
|
target.last_used_frame = frame_number_;
|
||||||
auto make_image = [&](VkFormat format, VkImageUsageFlags usage, VkImageAspectFlags aspect,
|
auto make_image = [&](VkFormat format, VkImageUsageFlags usage, VkImageAspectFlags aspect,
|
||||||
VkImage& image, VkDeviceMemory& memory, VkImageView& view) {
|
VkImage& image, VkDeviceMemory& memory, VkImageView& view, bool opaque = false) {
|
||||||
VkImageCreateInfo info{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
|
VkImageCreateInfo info{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
|
||||||
info.imageType = VK_IMAGE_TYPE_2D;
|
info.imageType = VK_IMAGE_TYPE_2D;
|
||||||
info.format = format;
|
info.format = format;
|
||||||
@@ -299,18 +307,20 @@ VulkanWindow::RenderTarget* VulkanWindow::get_render_target(const std::string& n
|
|||||||
VkImageViewCreateInfo view_info{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO};
|
VkImageViewCreateInfo view_info{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO};
|
||||||
view_info.image = image; view_info.viewType = VK_IMAGE_VIEW_TYPE_2D; view_info.format = format;
|
view_info.image = image; view_info.viewType = VK_IMAGE_VIEW_TYPE_2D; view_info.format = format;
|
||||||
view_info.subresourceRange = {aspect, 0, 1, 0, 1};
|
view_info.subresourceRange = {aspect, 0, 1, 0, 1};
|
||||||
|
// A colour target is D3DFMT_X8R8G8B8: sampled alpha is always 1.
|
||||||
|
if (opaque) view_info.components.a = VK_COMPONENT_SWIZZLE_ONE;
|
||||||
check(vkCreateImageView(device_, &view_info, nullptr, &view), "vkCreateImageView render target");
|
check(vkCreateImageView(device_, &view_info, nullptr, &view), "vkCreateImageView render target");
|
||||||
};
|
};
|
||||||
make_image(offscreen_format_,
|
make_image(color ? VK_FORMAT_R8G8B8A8_UNORM : offscreen_format_,
|
||||||
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT |
|
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT |
|
||||||
VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||||
VK_IMAGE_ASPECT_COLOR_BIT, target.texture.image, target.texture.memory, target.texture.view);
|
VK_IMAGE_ASPECT_COLOR_BIT, target.texture.image, target.texture.memory, target.texture.view, color);
|
||||||
make_image(VK_FORMAT_D32_SFLOAT,
|
make_image(VK_FORMAT_D32_SFLOAT,
|
||||||
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT,
|
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT,
|
||||||
VK_IMAGE_ASPECT_DEPTH_BIT, target.depth_image, target.depth_memory, target.depth_view);
|
VK_IMAGE_ASPECT_DEPTH_BIT, target.depth_image, target.depth_memory, target.depth_view);
|
||||||
const VkImageView views[2] = {target.texture.view, target.depth_view};
|
const VkImageView views[2] = {target.texture.view, target.depth_view};
|
||||||
VkFramebufferCreateInfo fb{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO};
|
VkFramebufferCreateInfo fb{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO};
|
||||||
fb.renderPass = offscreen_pass_;
|
fb.renderPass = color ? offscreen_color_pass_ : offscreen_pass_;
|
||||||
fb.attachmentCount = 2; fb.pAttachments = views;
|
fb.attachmentCount = 2; fb.pAttachments = views;
|
||||||
fb.width = w; fb.height = h; fb.layers = 1;
|
fb.width = w; fb.height = h; fb.layers = 1;
|
||||||
check(vkCreateFramebuffer(device_, &fb, nullptr, &target.framebuffer), "vkCreateFramebuffer render target");
|
check(vkCreateFramebuffer(device_, &fb, nullptr, &target.framebuffer), "vkCreateFramebuffer render target");
|
||||||
@@ -343,7 +353,8 @@ void VulkanWindow::initialize_render_target(RenderTarget& target) {
|
|||||||
barriers[1].subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1};
|
barriers[1].subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1};
|
||||||
vkCmdPipelineBarrier(f_->command, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
vkCmdPipelineBarrier(f_->command, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||||
0, 0, nullptr, 0, nullptr, 2, barriers);
|
0, 0, nullptr, 0, nullptr, 2, barriers);
|
||||||
const VkClearColorValue white{{1.0f, 1.0f, 1.0f, 1.0f}};
|
const float shade = target.color ? 0.0f : 1.0f;
|
||||||
|
const VkClearColorValue white{{shade, shade, shade, 1.0f}};
|
||||||
const VkClearDepthStencilValue far{1.0f, 0};
|
const VkClearDepthStencilValue far{1.0f, 0};
|
||||||
vkCmdClearColorImage(f_->command, target.texture.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &white, 1,
|
vkCmdClearColorImage(f_->command, target.texture.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &white, 1,
|
||||||
&barriers[0].subresourceRange);
|
&barriers[0].subresourceRange);
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ VulkanWindow::PreparedDraw VulkanWindow::scene_composite_draw() {
|
|||||||
for (int i = 0; i < 16; ++i) m[i] = i % 5 == 0 ? 1.0f : 0.0f;
|
for (int i = 0; i < 16; ++i) m[i] = i % 5 == 0 ? 1.0f : 0.0f;
|
||||||
PreparedDraw composite{};
|
PreparedDraw composite{};
|
||||||
composite.geometry = &scene_quad_;
|
composite.geometry = &scene_quad_;
|
||||||
composite.pipeline = get_pipeline(0, 0, 0, false, 4, false, &quad);
|
composite.pipeline = get_pipeline(0, 0, 0, false, 4, 0, &quad);
|
||||||
composite.descriptor = scene_texture_.descriptor;
|
composite.descriptor = scene_texture_.descriptor;
|
||||||
composite.constants = make_push_constants(quad, 0.0f);
|
composite.constants = make_push_constants(quad, 0.0f);
|
||||||
// Unit square (y down) to D3D NDC (y up), as the pretransformed draws map screen pixels.
|
// Unit square (y down) to D3D NDC (y up), as the pretransformed draws map screen pixels.
|
||||||
|
|||||||
@@ -430,6 +430,19 @@ void VulkanWindow::prune_textures() {
|
|||||||
it = textures_.erase(it);
|
it = textures_.erase(it);
|
||||||
} else ++it;
|
} else ++it;
|
||||||
}
|
}
|
||||||
|
// PORT: render targets too (a closed model preview); a later use recreates and clears one.
|
||||||
|
for (auto it = render_targets_.begin(); it != render_targets_.end();) {
|
||||||
|
if (frame_number_ - it->second.last_used_frame > static_retention_frames) {
|
||||||
|
for (auto pair = paired_descriptors_.begin(); pair != paired_descriptors_.end();) {
|
||||||
|
if (pair->first.find("\n" + it->first + "\n") != std::string::npos) {
|
||||||
|
vkFreeDescriptorSets(device_, descriptor_pool_, 1, &pair->second.set);
|
||||||
|
pair = paired_descriptors_.erase(pair);
|
||||||
|
} else ++pair;
|
||||||
|
}
|
||||||
|
release_render_target(it->second);
|
||||||
|
it = render_targets_.erase(it);
|
||||||
|
} else ++it;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace mt_host
|
} // namespace mt_host
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ VulkanWindow::~VulkanWindow() {
|
|||||||
for (auto& entry : textures_) release_texture(entry.second);
|
for (auto& entry : textures_) release_texture(entry.second);
|
||||||
for (auto& entry : render_targets_) release_render_target(entry.second);
|
for (auto& entry : render_targets_) release_render_target(entry.second);
|
||||||
if (offscreen_pass_) vkDestroyRenderPass(device_, offscreen_pass_, nullptr);
|
if (offscreen_pass_) vkDestroyRenderPass(device_, offscreen_pass_, nullptr);
|
||||||
|
if (offscreen_color_pass_) vkDestroyRenderPass(device_, offscreen_color_pass_, nullptr);
|
||||||
destroy_scene_target();
|
destroy_scene_target();
|
||||||
release_geometry(scene_quad_);
|
release_geometry(scene_quad_);
|
||||||
if (scene_pass_) vkDestroyRenderPass(device_, scene_pass_, nullptr);
|
if (scene_pass_) vkDestroyRenderPass(device_, scene_pass_, nullptr);
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ class VulkanWindow {
|
|||||||
VkDescriptorSet set = VK_NULL_HANDLE;
|
VkDescriptorSet set = VK_NULL_HANDLE;
|
||||||
std::uint64_t last_used_frame = 0;
|
std::uint64_t last_used_frame = 0;
|
||||||
};
|
};
|
||||||
// An offscreen D3D render-target texture (the 40250 character shadow map, "rt:<id>:<w>x<h>"):
|
// An offscreen D3D render-target texture (the 40250 character shadow map, "rt:<id>:<w>x<h>", or a
|
||||||
// drawn by the frame's offscreen pass ahead of the back-buffer pass and sampled by that pass.
|
// colour target such as the model preview, "rt:<id>:<w>x<h>:c"): drawn by the frame's offscreen pass
|
||||||
|
// ahead of the back-buffer pass and sampled by that pass.
|
||||||
// Between frames the colour image stays SHADER_READ_ONLY_OPTIMAL and the depth image
|
// Between frames the colour image stays SHADER_READ_ONLY_OPTIMAL and the depth image
|
||||||
// DEPTH_STENCIL_ATTACHMENT_OPTIMAL; the render pass keeps (LOAD/STORE) both, like a D3D surface.
|
// DEPTH_STENCIL_ATTACHMENT_OPTIMAL; the render pass keeps (LOAD/STORE) both, like a D3D surface.
|
||||||
struct RenderTarget {
|
struct RenderTarget {
|
||||||
@@ -67,7 +68,9 @@ class VulkanWindow {
|
|||||||
VkImageView depth_view = VK_NULL_HANDLE;
|
VkImageView depth_view = VK_NULL_HANDLE;
|
||||||
VkFramebuffer framebuffer = VK_NULL_HANDLE;
|
VkFramebuffer framebuffer = VK_NULL_HANDLE;
|
||||||
std::uint32_t width = 0, height = 0;
|
std::uint32_t width = 0, height = 0;
|
||||||
bool initialized = false; // cleared to white / depth 1 by the first frame that uses it
|
bool initialized = false; // cleared (shadow white, colour black) / depth 1 by the first frame that uses it
|
||||||
|
bool color = false; // RGBA8 drawn with offscreen_color_pass_; otherwise offscreen_format_
|
||||||
|
std::uint64_t last_used_frame = 0; // pruned with the textures once unused
|
||||||
};
|
};
|
||||||
struct PreparedDraw {
|
struct PreparedDraw {
|
||||||
Geometry* geometry = nullptr;
|
Geometry* geometry = nullptr;
|
||||||
@@ -223,7 +226,7 @@ private:
|
|||||||
};
|
};
|
||||||
// Host copies of the swapchain image (--screenshot-out) and, with MT_DUMP_RT, of every render target.
|
// Host copies of the swapchain image (--screenshot-out) and, with MT_DUMP_RT, of every render target.
|
||||||
struct Readback {
|
struct Readback {
|
||||||
struct RtDump { VkBuffer buffer; VkDeviceMemory memory; std::uint32_t w, h; };
|
struct RtDump { VkBuffer buffer; VkDeviceMemory memory; std::uint32_t w, h, texel; };
|
||||||
bool screenshot = false; // the swapchain image is copied (a BMP or a frame sink wants it)
|
bool screenshot = false; // the swapchain image is copied (a BMP or a frame sink wants it)
|
||||||
bool bmp = false; // --screenshot-out
|
bool bmp = false; // --screenshot-out
|
||||||
std::vector<FrameSink> sinks;
|
std::vector<FrameSink> sinks;
|
||||||
@@ -297,7 +300,9 @@ private:
|
|||||||
|
|
||||||
// The render pass of offscreen render-target textures: colour (R5G6B5 like the D3D surface when
|
// The render pass of offscreen render-target textures: colour (R5G6B5 like the D3D surface when
|
||||||
// the device can render to it) + D32 depth, both loaded and stored, colour left shader-readable.
|
// the device can render to it) + D32 depth, both loaded and stored, colour left shader-readable.
|
||||||
|
// PORT: plus offscreen_color_pass_, the same pass over RGBA8 for colour targets.
|
||||||
void create_offscreen_pass();
|
void create_offscreen_pass();
|
||||||
|
VkRenderPass make_offscreen_pass(VkFormat format);
|
||||||
|
|
||||||
// The reduced-resolution scene pass: pass_'s attachments (so pass_ pipelines draw in it) at
|
// The reduced-resolution scene pass: pass_'s attachments (so pass_ pipelines draw in it) at
|
||||||
// scene_extent_, resolving into the sampled scene_texture_. Unsupported when the swapchain
|
// scene_extent_, resolving into the sampled scene_texture_. Unsupported when the swapchain
|
||||||
@@ -322,9 +327,10 @@ private:
|
|||||||
// the layouts offscreen_pass_ expects. Recorded outside any render pass.
|
// the layouts offscreen_pass_ expects. Recorded outside any render pass.
|
||||||
void initialize_render_target(RenderTarget& target);
|
void initialize_render_target(RenderTarget& target);
|
||||||
|
|
||||||
// offscreen: for offscreen_pass_ (single-sampled render-target texture) instead of pass_.
|
// offscreen: 1 for offscreen_pass_, 2 for offscreen_color_pass_ (single-sampled render-target
|
||||||
|
// texture) instead of pass_.
|
||||||
VkPipeline get_pipeline(std::uint8_t cull, std::uint8_t depth, std::uint8_t blend,
|
VkPipeline get_pipeline(std::uint8_t cull, std::uint8_t depth, std::uint8_t blend,
|
||||||
bool lines = false, std::uint8_t z_func = 4, bool offscreen = false,
|
bool lines = false, std::uint8_t z_func = 4, std::uint8_t offscreen = 0,
|
||||||
const Render3DDraw* draw = nullptr);
|
const Render3DDraw* draw = nullptr);
|
||||||
|
|
||||||
VkDescriptorSet allocate_texture_descriptor_set(VkImageView view0, VkImageView view1,
|
VkDescriptorSet allocate_texture_descriptor_set(VkImageView view0, VkImageView view1,
|
||||||
@@ -478,6 +484,7 @@ private:
|
|||||||
std::unordered_map<std::string, PairedDescriptor> paired_descriptors_;
|
std::unordered_map<std::string, PairedDescriptor> paired_descriptors_;
|
||||||
std::unordered_map<std::string, RenderTarget> render_targets_;
|
std::unordered_map<std::string, RenderTarget> render_targets_;
|
||||||
VkRenderPass offscreen_pass_ = VK_NULL_HANDLE;
|
VkRenderPass offscreen_pass_ = VK_NULL_HANDLE;
|
||||||
|
VkRenderPass offscreen_color_pass_ = VK_NULL_HANDLE;
|
||||||
float render_scale_ = 1.0f;
|
float render_scale_ = 1.0f;
|
||||||
bool scene_supported_ = false;
|
bool scene_supported_ = false;
|
||||||
VkRenderPass scene_pass_ = VK_NULL_HANDLE;
|
VkRenderPass scene_pass_ = VK_NULL_HANDLE;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
set(mt_mesh_default OFF)
|
||||||
|
if(APPLE OR ANDROID)
|
||||||
|
set(mt_mesh_default ON)
|
||||||
|
endif()
|
||||||
|
option(MT_ENABLE_MESHPORT "Embed MeshPort application networking" ${mt_mesh_default})
|
||||||
|
if(NOT MT_ENABLE_MESHPORT)
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
set(MT_MESHPORT_ROOT "${PROJECT_SOURCE_DIR}/third_party/meshport" CACHE PATH "MeshPort source checkout")
|
||||||
|
if(ANDROID)
|
||||||
|
set(mt_mesh_library "${PROJECT_SOURCE_DIR}/build/meshport-runtime/aarch64-linux-android/release/libmetin2_meshport.a")
|
||||||
|
else()
|
||||||
|
set(mt_mesh_library "${PROJECT_SOURCE_DIR}/build/meshport-runtime/release/libmetin2_meshport.a")
|
||||||
|
endif()
|
||||||
|
set(MT_MESHPORT_LIBRARY "${mt_mesh_library}" CACHE FILEPATH "MeshPort static library for this target")
|
||||||
|
if(NOT EXISTS "${MT_MESHPORT_LIBRARY}")
|
||||||
|
message(FATAL_ERROR "Build MeshPort first: scripts/build_meshport.sh (or set MT_MESHPORT_LIBRARY)")
|
||||||
|
endif()
|
||||||
|
# A private build input, never copy network credentials into the source tree.
|
||||||
|
set(mt_mesh_config "${PROJECT_SOURCE_DIR}/config/meshport.private.toml")
|
||||||
|
if(EXISTS "$ENV{HOME}/Library/Application Support/MeshPort/network-profile.toml")
|
||||||
|
set(mt_mesh_config "$ENV{HOME}/Library/Application Support/MeshPort/network-profile.toml")
|
||||||
|
endif()
|
||||||
|
set(MT_MESHPORT_CONFIG "${mt_mesh_config}"
|
||||||
|
CACHE FILEPATH "Saved MeshPort network configuration to embed")
|
||||||
|
set(MT_MESHPORT_TOML "")
|
||||||
|
if(EXISTS "${MT_MESHPORT_CONFIG}")
|
||||||
|
file(READ "${MT_MESHPORT_CONFIG}" MT_MESHPORT_TOML)
|
||||||
|
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${MT_MESHPORT_CONFIG}")
|
||||||
|
else()
|
||||||
|
message(WARNING "MeshPort network profile missing: LAN login works, remote login requires MT_MESHPORT_CONFIG")
|
||||||
|
endif()
|
||||||
|
configure_file(config.h.in "${CMAKE_CURRENT_BINARY_DIR}/MeshPortConfig.h" @ONLY)
|
||||||
|
add_library(mt_meshport STATIC transport.cpp)
|
||||||
|
target_include_directories(mt_meshport PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||||
|
PRIVATE "${MT_MESHPORT_ROOT}/crates/meshport-ffi/include")
|
||||||
|
target_compile_definitions(mt_meshport PUBLIC MT_HAS_MESHPORT=1)
|
||||||
|
target_link_libraries(mt_meshport PUBLIC "${MT_MESHPORT_LIBRARY}")
|
||||||
|
if(APPLE)
|
||||||
|
target_link_libraries(mt_meshport PUBLIC "-framework Security" "-framework SystemConfiguration"
|
||||||
|
"-framework CoreFoundation" resolv iconv)
|
||||||
|
elseif(ANDROID)
|
||||||
|
target_link_libraries(mt_meshport PUBLIC log android dl m)
|
||||||
|
endif()
|
||||||
|
if(BUILD_TESTING AND NOT ANDROID)
|
||||||
|
add_executable(meshport_transport_test "${PROJECT_SOURCE_DIR}/tests/meshport/transport_test.cpp")
|
||||||
|
target_link_libraries(meshport_transport_test PRIVATE mt_meshport)
|
||||||
|
add_test(NAME meshport.transport COMMAND meshport_transport_test)
|
||||||
|
add_executable(meshport_network_paths_test "${PROJECT_SOURCE_DIR}/tests/meshport/network_paths_test.cpp")
|
||||||
|
target_link_libraries(meshport_network_paths_test PRIVATE mt_meshport)
|
||||||
|
find_package(Python3 COMPONENTS Interpreter)
|
||||||
|
if(Python3_FOUND)
|
||||||
|
add_test(NAME meshport.login COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/meshport/test_login.py")
|
||||||
|
if(EXISTS "${PROJECT_SOURCE_DIR}/build/meshport-runtime/release/examples/gateway_fixture")
|
||||||
|
add_test(NAME meshport.network_paths COMMAND "${Python3_EXECUTABLE}"
|
||||||
|
"${PROJECT_SOURCE_DIR}/tests/meshport/test_network_paths.py" "$<TARGET_FILE:meshport_network_paths_test>")
|
||||||
|
set_tests_properties(meshport.network_paths PROPERTIES TIMEOUT 120)
|
||||||
|
endif()
|
||||||
|
if(APPLE AND EXISTS "${PROJECT_SOURCE_DIR}/build/meshport-runtime/release/libmetin2_meshport.dylib")
|
||||||
|
add_test(NAME meshport.dhcp COMMAND "${Python3_EXECUTABLE}"
|
||||||
|
"${PROJECT_SOURCE_DIR}/tests/meshport/sdk_smoke.py")
|
||||||
|
set_tests_properties(meshport.dhcp PROPERTIES TIMEOUT 55)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
// Generated private build input. Network credentials are intentionally not logged.
|
||||||
|
static const char* const kMeshPortConfig = R"MESHCONFIG(@MT_MESHPORT_TOML@)MESHCONFIG";
|
||||||
Generated
+4911
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "metin2-meshport"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.95"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["staticlib", "cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
meshport-ffi = { path = "../../../third_party/meshport/crates/meshport-ffi" }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
easytier = { path = "../../../third_party/meshport/vendor/easytier/easytier", default-features = false, features = ["aes-gcm", "ffi-dataplane", "proxy-cidr-monitor", "dhcp-ipv4", "tcp-hole-punch", "proxy-packet"] }
|
||||||
|
easytier-core = { path = "../../../third_party/meshport/vendor/easytier/easytier-core", default-features = false, features = ["proxy-smoltcp-stack"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//! Isolated test gateway. Maps a private advertised subnet to loopback; no OS routes/TUN.
|
||||||
|
use easytier::instance::factory::create_native_instance;
|
||||||
|
use easytier_core::config::toml::{ConfigLoader, TomlConfig};
|
||||||
|
use tokio::{net::{TcpListener,UdpSocket},io::{AsyncReadExt,AsyncWriteExt}};
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let tcp=TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let tcp_port=tcp.local_addr().unwrap().port();
|
||||||
|
let udp=UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let udp_port=udp.local_addr().unwrap().port();
|
||||||
|
let reserve=std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let mesh_port=reserve.local_addr().unwrap().port();drop(reserve);
|
||||||
|
let name=format!("metin2-gateway-test-{}",std::process::id());
|
||||||
|
let config=format!("ipv4='10.253.85.1/24'\nlisteners=['tcp://127.0.0.1:{mesh_port}']\nstun_servers=[]\nstun_servers_v6=[]\n[[proxy_network]]\ncidr='127.0.0.0/24'\nmapped_cidr='10.253.79.0/24'\n[network_identity]\nnetwork_name='{name}'\nnetwork_secret='isolated-test-only'\n[flags]\nno_tun=true\nuse_smoltcp=true\nenable_encryption=true\n");
|
||||||
|
let parsed=TomlConfig::new_from_str(&config).unwrap();
|
||||||
|
eprintln!("fixture proxy networks: {:?}",parsed.get_proxy_cidrs());
|
||||||
|
let server=create_native_instance(parsed).unwrap();
|
||||||
|
server.start().await.unwrap();
|
||||||
|
println!("{{\"mesh_port\":{mesh_port},\"tcp_port\":{tcp_port},\"udp_port\":{udp_port},\"network_name\":\"{name}\"}}");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok((mut stream,_))=tcp.accept().await {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut data=[0u8;16384];
|
||||||
|
while let Ok(size)=stream.read(&mut data).await {
|
||||||
|
if size==0 {break}
|
||||||
|
if data[..size].starts_with(b"POST /register") {
|
||||||
|
let _=stream.write_all(b"HTTP/1.0 200 OK\r\nContent-Length: 3\r\n\r\nOK\n").await;break
|
||||||
|
}
|
||||||
|
if stream.write_all(&data[..size]).await.is_err(){break}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut data=[0u8;65536];
|
||||||
|
loop {
|
||||||
|
if let Ok((size,peer))=udp.recv_from(&mut data).await {let _=udp.send_to(&data[..size],peer).await;}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
//! Metin2 host policy around the vendored MeshPort SDK. No sockets or OS routes are intercepted.
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::{collections::HashMap, ffi::{c_char, CStr, CString}, net::Ipv4Addr,
|
||||||
|
sync::{Arc, Condvar, Mutex, OnceLock}, thread::{self, JoinHandle}, time::{Duration, Instant}};
|
||||||
|
|
||||||
|
struct Target { since: Instant, touched: Instant }
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
config: String, targets: HashMap<u32, Target>, routes: Vec<String>, port: u16,
|
||||||
|
stop: bool, failed: bool, retry: bool, event: i32, connected: bool,
|
||||||
|
}
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Runtime { state: Arc<(Mutex<State>, Condvar)>, worker: Option<JoinHandle<()>> }
|
||||||
|
static RUNTIME: OnceLock<Mutex<Runtime>> = OnceLock::new();
|
||||||
|
fn runtime() -> &'static Mutex<Runtime> { RUNTIME.get_or_init(|| Mutex::new(Runtime::default())) }
|
||||||
|
|
||||||
|
fn matches(cidr: &str, address: u32) -> bool {
|
||||||
|
let Some((ip, bits)) = cidr.split_once('/') else { return false };
|
||||||
|
let (Ok(ip), Ok(bits)) = (ip.parse::<Ipv4Addr>(), bits.parse::<u32>()) else { return false };
|
||||||
|
if !(1..=32).contains(&bits) { return false }
|
||||||
|
let mask = u32::MAX << (32 - bits);
|
||||||
|
(u32::from(ip) & mask) == (address & mask)
|
||||||
|
}
|
||||||
|
fn call(context: u64, request: Value) -> Result<Value, ()> {
|
||||||
|
let text = CString::new(request.to_string()).map_err(|_| ())?;
|
||||||
|
let pointer = unsafe { meshport_ffi::meshport_call(context, text.as_ptr()) };
|
||||||
|
if pointer.is_null() { return Err(()) }
|
||||||
|
let decoded = unsafe { serde_json::from_slice::<Value>(CStr::from_ptr(pointer).to_bytes()) };
|
||||||
|
unsafe { meshport_ffi::meshport_string_free(pointer) };
|
||||||
|
let decoded = decoded.map_err(|_| ())?;
|
||||||
|
if decoded["ok"] != true {
|
||||||
|
if std::env::var_os("MT_MESHPORT_TRACE").is_some() { eprintln!("MeshPort SDK operation failed: {}",request["op"]); }
|
||||||
|
return Err(())
|
||||||
|
}
|
||||||
|
Ok(decoded["data"].clone())
|
||||||
|
}
|
||||||
|
fn start(context: u64, config: &str) -> Result<u16, ()> {
|
||||||
|
let info = call(context, json!({"op":"abi_info"}))?;
|
||||||
|
if info["abi"] != 1 || info["revision"].as_u64().unwrap_or(0) < 2 { return Err(()) }
|
||||||
|
let migrated = call(context, json!({"op":"migrate_config","toml":config}))?;
|
||||||
|
let mut profile = call(context, json!({"op":"validate","toml":migrated["toml"],"include_profile":true}))?["profile"].clone();
|
||||||
|
profile["runtime_mode"] = json!("application");
|
||||||
|
profile["dhcp"] = json!(true); profile["ipv4"] = json!("");
|
||||||
|
profile["hostname"] = json!("Metin2"); profile["listeners"] = json!([]);
|
||||||
|
let built = call(context, json!({"op":"build_config","profile":profile}))?;
|
||||||
|
let started = call(context, json!({"op":"start_bridged","toml":built["toml"],"check_local_overlap":true}))?;
|
||||||
|
let listen = started["listen"].as_str().ok_or(())?;
|
||||||
|
let port = listen.strip_prefix("127.0.0.1:").ok_or(())?.parse::<u16>().map_err(|_| ())?;
|
||||||
|
if port == 0 { return Err(()) }
|
||||||
|
Ok(port)
|
||||||
|
}
|
||||||
|
fn run(shared: Arc<(Mutex<State>, Condvar)>) {
|
||||||
|
let (lock, wake) = &*shared;
|
||||||
|
let mut context = 0;
|
||||||
|
let mut last_projection = String::new();
|
||||||
|
loop {
|
||||||
|
let mut state = lock.lock().unwrap();
|
||||||
|
if state.stop { break }
|
||||||
|
if state.failed && !state.retry || context == 0 && state.targets.is_empty() {
|
||||||
|
drop(wake.wait_timeout(state, Duration::from_secs(1)).unwrap());
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if state.retry {
|
||||||
|
state.retry = false; state.failed = false; state.routes.clear(); state.port = 0;
|
||||||
|
state.connected = false; state.event = 1;
|
||||||
|
drop(state);
|
||||||
|
if context != 0 { meshport_ffi::meshport_context_free(context); context = 0; }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if context == 0 {
|
||||||
|
state.event = 1; state.connected = false;
|
||||||
|
let config = state.config.clone(); drop(state);
|
||||||
|
context = meshport_ffi::meshport_context_new();
|
||||||
|
let prepared = if context == 0 { Err(()) } else { start(context, &config) };
|
||||||
|
state = lock.lock().unwrap();
|
||||||
|
match prepared {
|
||||||
|
Ok(port) => state.port = port,
|
||||||
|
Err(_) => {
|
||||||
|
state.failed = true; state.event = 3; state.port = 0;
|
||||||
|
drop(state);
|
||||||
|
if context != 0 { meshport_ffi::meshport_context_free(context); context = 0; }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
let snapshot = call(context, json!({"op":"status"}));
|
||||||
|
state = lock.lock().unwrap();
|
||||||
|
match snapshot {
|
||||||
|
Ok(data) if data["health"]["engine_running"] == true && data["health"]["bridge_running"] == true => {
|
||||||
|
let ready = data["health"]["mesh_routable"] == true && data["platform_network"]["overlay"]["address"].as_str().is_some_and(|ip| ip != "0.0.0.0");
|
||||||
|
if std::env::var_os("MT_MESHPORT_TRACE").is_some() {
|
||||||
|
let projection = format!("ready={ready} overlay={} routes={}",data["platform_network"]["overlay"]["address"],data["platform_network"]["mesh_cidrs"]);
|
||||||
|
if projection != last_projection { eprintln!("MeshPort {projection}"); last_projection = projection; }
|
||||||
|
}
|
||||||
|
state.routes = if ready { data["platform_network"]["mesh_cidrs"].as_array()
|
||||||
|
.map(|items| items.iter().filter_map(|v|v.as_str().map(String::from)).collect()).unwrap_or_default() } else { vec![] };
|
||||||
|
}
|
||||||
|
_ => { state.routes.clear(); state.port = 0; state.failed = true; state.event = 3; }
|
||||||
|
}
|
||||||
|
state.targets.retain(|_,target| target.touched.elapsed() < Duration::from_secs(120));
|
||||||
|
let interval = if state.connected { 1000 } else { 250 };
|
||||||
|
drop(wake.wait_timeout(state, Duration::from_millis(interval)).unwrap());
|
||||||
|
}
|
||||||
|
if context != 0 { meshport_ffi::meshport_context_free(context); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a private profile; no network starts until a remote socket asks for a route.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn mt_mesh_configure(config: *const c_char) {
|
||||||
|
if config.is_null() { return }
|
||||||
|
let Ok(config) = unsafe { CStr::from_ptr(config) }.to_str() else { return };
|
||||||
|
let rt = runtime().lock().unwrap();
|
||||||
|
rt.state.0.lock().unwrap().config = config.to_owned();
|
||||||
|
}
|
||||||
|
/// Host-order IPv4. Positive = healthy bridge port, zero = waiting, -1 = unavailable.
|
||||||
|
/// fresh=true only on a new operation; polling must not restart its timeout or retry.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn mt_mesh_route(address: u32, fresh: bool) -> i32 {
|
||||||
|
let mut rt = runtime().lock().unwrap();
|
||||||
|
let mut state = rt.state.0.lock().unwrap();
|
||||||
|
if state.stop { return -1 }
|
||||||
|
let now = Instant::now();
|
||||||
|
if fresh && state.failed { state.retry = true; }
|
||||||
|
let target = state.targets.entry(address).or_insert(Target { since: now, touched: now });
|
||||||
|
if fresh { target.since = now; }
|
||||||
|
target.touched = now;
|
||||||
|
let expired = target.since.elapsed() > Duration::from_secs(60);
|
||||||
|
let answer = if state.failed && !state.retry || expired { -1 }
|
||||||
|
else if !state.retry && state.port != 0 && state.routes.iter().any(|cidr|matches(cidr,address)) { state.port as i32 }
|
||||||
|
else { 0 };
|
||||||
|
if expired && !state.connected { state.event = 3; }
|
||||||
|
drop(state);
|
||||||
|
if rt.worker.is_none() {
|
||||||
|
let shared = rt.state.clone(); rt.worker = Some(thread::spawn(move ||run(shared)));
|
||||||
|
}
|
||||||
|
if fresh { rt.state.1.notify_one(); }
|
||||||
|
answer
|
||||||
|
}
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn mt_mesh_connected() {
|
||||||
|
let rt = runtime().lock().unwrap(); let mut state = rt.state.0.lock().unwrap();
|
||||||
|
if !state.connected { state.connected = true; state.event = 2; }
|
||||||
|
}
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn mt_mesh_event() -> i32 {
|
||||||
|
let rt = runtime().lock().unwrap(); let mut state = rt.state.0.lock().unwrap();
|
||||||
|
let event = state.event; state.event = 0; event
|
||||||
|
}
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn mt_mesh_shutdown() {
|
||||||
|
let worker = {
|
||||||
|
let mut rt = runtime().lock().unwrap();
|
||||||
|
rt.state.0.lock().unwrap().stop = true; rt.state.1.notify_all(); rt.worker.take()
|
||||||
|
};
|
||||||
|
if let Some(worker) = worker { let _ = worker.join(); }
|
||||||
|
let mut rt = runtime().lock().unwrap(); rt.state = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
#include "transport.h"
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <ifaddrs.h>
|
||||||
|
#include <net/if.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstring>
|
||||||
|
#include <map>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <chrono>
|
||||||
|
#include <deque>
|
||||||
|
#include <set>
|
||||||
|
#include <array>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
void mt_mesh_configure(const char*);
|
||||||
|
int mt_mesh_route(uint32_t, bool);
|
||||||
|
void mt_mesh_connected();
|
||||||
|
int mt_mesh_event();
|
||||||
|
void mt_mesh_shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace mt::meshport {
|
||||||
|
namespace {
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
struct Handshake {
|
||||||
|
sockaddr_in target{};
|
||||||
|
const void* owner = nullptr;
|
||||||
|
int stage = -1;
|
||||||
|
size_t offset = 0;
|
||||||
|
unsigned char bytes[10] = {5, 1, 0};
|
||||||
|
unsigned char command = 1;
|
||||||
|
Clock::time_point deadline = Clock::now() + std::chrono::seconds(75);
|
||||||
|
};
|
||||||
|
struct Packet { sockaddr_in target{}; std::vector<unsigned char> bytes; Clock::time_point deadline; };
|
||||||
|
struct Udp {
|
||||||
|
int control = -1, relay = -1;
|
||||||
|
uint16_t port = 0;
|
||||||
|
Handshake handshake;
|
||||||
|
bool ready = false;
|
||||||
|
std::deque<Packet> outgoing, incoming;
|
||||||
|
std::set<std::pair<uint32_t,uint16_t>> peers;
|
||||||
|
size_t queued_bytes = 0;
|
||||||
|
};
|
||||||
|
std::mutex transport_mutex;
|
||||||
|
std::map<int, Handshake> handshakes;
|
||||||
|
std::map<int, Udp> datagrams;
|
||||||
|
std::vector<std::string> routes;
|
||||||
|
sockaddr_in proxy{};
|
||||||
|
bool fixture = false;
|
||||||
|
std::atomic<int> transport_event{0};
|
||||||
|
bool ipv4(const std::string& text, uint32_t& result) {
|
||||||
|
in_addr ip{};
|
||||||
|
if (inet_pton(AF_INET, text.c_str(), &ip) != 1) return false;
|
||||||
|
result = ip.s_addr;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::string address(uint32_t ip) {
|
||||||
|
char buf[INET_ADDRSTRLEN];
|
||||||
|
in_addr value{}; value.s_addr = ip;
|
||||||
|
return inet_ntop(AF_INET, &value, buf, sizeof(buf)) ? buf : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool in_subnet(uint32_t ip, uint32_t local, uint32_t mask) {
|
||||||
|
return mask != 0 && (ip & mask) == (local & mask);
|
||||||
|
}
|
||||||
|
bool contains(const std::string& cidr, const std::string& ip) {
|
||||||
|
const auto slash = cidr.find('/');
|
||||||
|
if (slash == std::string::npos) return false;
|
||||||
|
uint32_t network, target;
|
||||||
|
if (!ipv4(cidr.substr(0, slash), network) || !ipv4(ip, target)) return false;
|
||||||
|
const auto suffix = cidr.substr(slash + 1);
|
||||||
|
if (suffix.empty() || suffix.find_first_not_of("0123456789") != std::string::npos) return false;
|
||||||
|
if (suffix.size() > 2) return false;
|
||||||
|
const int prefix = std::stoi(suffix);
|
||||||
|
if (prefix < 1 || prefix > 32) return false;
|
||||||
|
return in_subnet(target, network, htonl(0xffffffffu << (32 - prefix)));
|
||||||
|
}
|
||||||
|
bool same_subnet(const std::string& ip) {
|
||||||
|
uint32_t target;
|
||||||
|
if (!ipv4(ip, target)) return false;
|
||||||
|
if ((ntohl(target) >> 24) == 127) return true;
|
||||||
|
ifaddrs* interfaces = nullptr;
|
||||||
|
if (getifaddrs(&interfaces) != 0) return false;
|
||||||
|
bool local = false;
|
||||||
|
for (auto* i = interfaces; i; i = i->ifa_next) {
|
||||||
|
if (!i->ifa_addr || !i->ifa_netmask || i->ifa_addr->sa_family != AF_INET) continue;
|
||||||
|
if (!(i->ifa_flags & IFF_UP) || (i->ifa_flags & (IFF_LOOPBACK | IFF_POINTOPOINT))) continue;
|
||||||
|
const std::string name = i->ifa_name;
|
||||||
|
if (name.find("tun") == 0 || name.find("utun") == 0 || name.find("tap") == 0) continue;
|
||||||
|
const auto* a = reinterpret_cast<sockaddr_in*>(i->ifa_addr);
|
||||||
|
const auto* m = reinterpret_cast<sockaddr_in*>(i->ifa_netmask);
|
||||||
|
if (in_subnet(target, a->sin_addr.s_addr, m->sin_addr.s_addr)) { local = true; break; }
|
||||||
|
}
|
||||||
|
freeifaddrs(interfaces);
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
void set_routes(const std::string& listen, const std::vector<std::string>& cidrs) {
|
||||||
|
sockaddr_in next{};
|
||||||
|
if (!listen.empty()) {
|
||||||
|
const auto colon = listen.find(':');
|
||||||
|
if (colon == std::string::npos || listen.substr(0, colon) != "127.0.0.1")
|
||||||
|
throw std::runtime_error("MeshPort bridge must listen on loopback");
|
||||||
|
const auto port_text = listen.substr(colon + 1);
|
||||||
|
if (port_text.empty() || port_text.size() > 5 || port_text.find_first_not_of("0123456789") != std::string::npos)
|
||||||
|
throw std::runtime_error("Invalid MeshPort bridge port");
|
||||||
|
int port = std::stoi(port_text);
|
||||||
|
if (port < 1 || port > 65535) throw std::runtime_error("Invalid MeshPort bridge port");
|
||||||
|
next.sin_family = AF_INET;
|
||||||
|
next.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||||
|
next.sin_port = htons(port);
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> guard(transport_mutex);
|
||||||
|
proxy = next;
|
||||||
|
routes = cidrs;
|
||||||
|
fixture = !listen.empty();
|
||||||
|
}
|
||||||
|
void configure(const std::string& config) { mt_mesh_configure(config.c_str()); }
|
||||||
|
namespace {
|
||||||
|
int route(const sockaddr_in& target, bool fresh) {
|
||||||
|
if (fixture) {
|
||||||
|
for (const auto& cidr : routes)
|
||||||
|
if (contains(cidr, address(target.sin_addr.s_addr))) return ntohs(proxy.sin_port);
|
||||||
|
return -1; // Missing routes never fall back to the OS network.
|
||||||
|
}
|
||||||
|
return mt_mesh_route(ntohl(target.sin_addr.s_addr), fresh);
|
||||||
|
}
|
||||||
|
sockaddr_in loopback(int port) {
|
||||||
|
sockaddr_in value{}; value.sin_family = AF_INET;
|
||||||
|
value.sin_addr.s_addr = htonl(INADDR_LOOPBACK); value.sin_port = htons(port); return value;
|
||||||
|
}
|
||||||
|
int fail(int error) { errno = error; transport_event = 3; return -1; }
|
||||||
|
int advance(int fd, Handshake& h, sockaddr_in* bound = nullptr) {
|
||||||
|
if (Clock::now() > h.deadline) return fail(ETIMEDOUT);
|
||||||
|
if (h.stage == -1) {
|
||||||
|
int port = route(h.target, false);
|
||||||
|
if (port < 0) return fail(EHOSTUNREACH);
|
||||||
|
if (!port) return 0;
|
||||||
|
auto endpoint = loopback(port);
|
||||||
|
int status = ::connect(fd, reinterpret_cast<const sockaddr*>(&endpoint), sizeof(endpoint));
|
||||||
|
if (status < 0 && errno != EINPROGRESS && errno != EWOULDBLOCK) return fail(errno);
|
||||||
|
h.stage = 0;
|
||||||
|
h.deadline = Clock::now() + std::chrono::seconds(10);
|
||||||
|
}
|
||||||
|
if (h.stage == 0) {
|
||||||
|
int error = 0; socklen_t len = sizeof(error);
|
||||||
|
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &len) < 0) return fail(errno);
|
||||||
|
if (error) return fail(error);
|
||||||
|
sockaddr_in peer{}; len = sizeof(peer);
|
||||||
|
if (getpeername(fd, reinterpret_cast<sockaddr*>(&peer), &len) < 0) return 0;
|
||||||
|
h.stage = 1;
|
||||||
|
}
|
||||||
|
const bool sending = h.stage == 1 || h.stage == 3;
|
||||||
|
const size_t length = h.stage == 1 ? 3 : h.stage == 2 ? 2 : 10;
|
||||||
|
#ifdef MSG_NOSIGNAL
|
||||||
|
constexpr int send_flags = MSG_NOSIGNAL;
|
||||||
|
#else
|
||||||
|
constexpr int send_flags = 0;
|
||||||
|
#endif
|
||||||
|
const auto count = sending ? ::send(fd, h.bytes + h.offset, length - h.offset, send_flags)
|
||||||
|
: ::recv(fd, h.bytes + h.offset, length - h.offset, 0);
|
||||||
|
if (count < 0) return (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) ? 0 : fail(errno);
|
||||||
|
if (!count) return fail(ECONNRESET);
|
||||||
|
h.offset += static_cast<size_t>(count);
|
||||||
|
if (h.offset != length) return 0;
|
||||||
|
h.offset = 0;
|
||||||
|
if (h.stage == 2) {
|
||||||
|
if (h.bytes[0] != 5 || h.bytes[1] != 0) return fail(EPROTO);
|
||||||
|
h.bytes[0] = 5; h.bytes[1] = h.command; h.bytes[2] = 0; h.bytes[3] = 1;
|
||||||
|
if (h.command == 1) {
|
||||||
|
std::memcpy(h.bytes + 4, &h.target.sin_addr.s_addr, 4);
|
||||||
|
std::memcpy(h.bytes + 8, &h.target.sin_port, 2);
|
||||||
|
} else { std::memset(h.bytes + 4, 0, 6); }
|
||||||
|
} else if (h.stage == 4) {
|
||||||
|
if (h.bytes[0] != 5 || h.bytes[1] != 0 || h.bytes[2] != 0 || h.bytes[3] != 1) return fail(EPROTO);
|
||||||
|
if (bound) {
|
||||||
|
bound->sin_family = AF_INET;
|
||||||
|
std::memcpy(&bound->sin_addr.s_addr, h.bytes + 4, 4);
|
||||||
|
std::memcpy(&bound->sin_port, h.bytes + 8, 2);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
++h.stage; return 0;
|
||||||
|
}
|
||||||
|
void close_udp(Udp& udp) {
|
||||||
|
if (udp.control >= 0) ::close(udp.control);
|
||||||
|
if (udp.relay >= 0) ::close(udp.relay);
|
||||||
|
udp.control = udp.relay = -1; udp.ready = false; udp.port = 0;
|
||||||
|
}
|
||||||
|
void pump_udp(int fd, Udp& udp) {
|
||||||
|
const auto now = Clock::now();
|
||||||
|
while (!udp.outgoing.empty() && udp.outgoing.front().deadline < now) {
|
||||||
|
udp.queued_bytes -= udp.outgoing.front().bytes.size(); udp.outgoing.pop_front(); transport_event = 3;
|
||||||
|
}
|
||||||
|
// A queued request can become local while DHCP/peer discovery is pending.
|
||||||
|
while (!udp.outgoing.empty() && same_subnet(address(udp.outgoing.front().target.sin_addr.s_addr))) {
|
||||||
|
auto& packet = udp.outgoing.front();
|
||||||
|
auto count = ::sendto(fd,packet.bytes.data(),packet.bytes.size(),MSG_DONTWAIT,
|
||||||
|
reinterpret_cast<sockaddr*>(&packet.target),sizeof(packet.target));
|
||||||
|
if (count < 0 && (errno == EWOULDBLOCK || errno == EAGAIN || errno == EINTR)) break;
|
||||||
|
udp.queued_bytes -= packet.bytes.size(); udp.outgoing.pop_front();
|
||||||
|
}
|
||||||
|
if (udp.control >= 0 && udp.ready) {
|
||||||
|
char probe;
|
||||||
|
auto result = ::recv(udp.control, &probe, 1, MSG_PEEK | MSG_DONTWAIT);
|
||||||
|
if (result == 0 || (result < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) close_udp(udp);
|
||||||
|
}
|
||||||
|
if (udp.control < 0 && !udp.outgoing.empty()) {
|
||||||
|
auto& packet = udp.outgoing.front();
|
||||||
|
const int port = route(packet.target, false);
|
||||||
|
if (port < 0) {
|
||||||
|
udp.queued_bytes -= packet.bytes.size(); udp.outgoing.pop_front(); transport_event = 3; return;
|
||||||
|
}
|
||||||
|
if (!port) return;
|
||||||
|
udp.control = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (udp.control < 0) return;
|
||||||
|
fcntl(udp.control, F_SETFL, O_NONBLOCK);
|
||||||
|
#ifdef SO_NOSIGPIPE
|
||||||
|
int one=1;setsockopt(udp.control,SOL_SOCKET,SO_NOSIGPIPE,&one,sizeof(one));
|
||||||
|
#endif
|
||||||
|
udp.port = port; udp.handshake = Handshake{};
|
||||||
|
udp.handshake.target = packet.target; udp.handshake.command = 3;
|
||||||
|
}
|
||||||
|
if (udp.control < 0) return;
|
||||||
|
if (!udp.ready) {
|
||||||
|
sockaddr_in relay{};
|
||||||
|
int result = advance(udp.control, udp.handshake, &relay);
|
||||||
|
if (result < 0) { close_udp(udp); return; }
|
||||||
|
if (!result) return;
|
||||||
|
// The SDK bridge is IPv4 loopback-only. Do not accept a redirect to an external relay.
|
||||||
|
if (relay.sin_addr.s_addr != htonl(INADDR_LOOPBACK) || !relay.sin_port) { close_udp(udp); transport_event=3;return; }
|
||||||
|
udp.relay = ::socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
if (udp.relay < 0) { close_udp(udp);return; }
|
||||||
|
fcntl(udp.relay,F_SETFL,O_NONBLOCK);
|
||||||
|
if (::connect(udp.relay,reinterpret_cast<sockaddr*>(&relay),sizeof(relay)) < 0) {close_udp(udp);return;}
|
||||||
|
udp.ready = true;
|
||||||
|
}
|
||||||
|
for (size_t sent=0; !udp.outgoing.empty() && sent<32; ++sent) {
|
||||||
|
auto& packet = udp.outgoing.front();
|
||||||
|
const int port = route(packet.target,false);
|
||||||
|
if (port == 0) break;
|
||||||
|
if (port < 0) {
|
||||||
|
udp.queued_bytes -= packet.bytes.size(); udp.outgoing.pop_front(); transport_event=3;continue;
|
||||||
|
}
|
||||||
|
if (port != udp.port) {close_udp(udp);return;}
|
||||||
|
std::vector<unsigned char> envelope(10+packet.bytes.size());
|
||||||
|
envelope[3]=1;
|
||||||
|
std::memcpy(envelope.data()+4,&packet.target.sin_addr.s_addr,4);
|
||||||
|
std::memcpy(envelope.data()+8,&packet.target.sin_port,2);
|
||||||
|
std::copy(packet.bytes.begin(),packet.bytes.end(),envelope.begin()+10);
|
||||||
|
auto count = ::send(udp.relay,envelope.data(),envelope.size(),0);
|
||||||
|
if (count < 0 && (errno == EWOULDBLOCK || errno == EAGAIN || errno == EINTR)) break;
|
||||||
|
if (count < 0) {close_udp(udp);return;}
|
||||||
|
if(udp.peers.size()<128)udp.peers.emplace(packet.target.sin_addr.s_addr,packet.target.sin_port);
|
||||||
|
udp.queued_bytes -= packet.bytes.size(); udp.outgoing.pop_front();
|
||||||
|
}
|
||||||
|
for (int i=0;i<32;++i) {
|
||||||
|
std::array<unsigned char,65536> envelope;
|
||||||
|
auto count = ::recv(udp.relay,envelope.data(),envelope.size(),0);
|
||||||
|
if (count < 0) break;
|
||||||
|
if (count<10 || envelope[0] || envelope[1] || envelope[2] || envelope[3]!=1) continue;
|
||||||
|
sockaddr_in source{};source.sin_family=AF_INET;
|
||||||
|
std::memcpy(&source.sin_addr.s_addr,envelope.data()+4,4);
|
||||||
|
std::memcpy(&source.sin_port,envelope.data()+8,2);
|
||||||
|
if (!udp.peers.count({source.sin_addr.s_addr,source.sin_port})) continue;
|
||||||
|
if (udp.incoming.size()>=64) continue;
|
||||||
|
udp.incoming.push_back({source,{envelope.begin()+10,envelope.begin()+count},now});
|
||||||
|
if (!fixture) mt_mesh_connected(); else transport_event=2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int connect_socket(int fd, const sockaddr_in& target, const void* owner) {
|
||||||
|
if (same_subnet(address(target.sin_addr.s_addr)))
|
||||||
|
return ::connect(fd,reinterpret_cast<const sockaddr*>(&target),sizeof(target));
|
||||||
|
std::lock_guard<std::mutex> guard(transport_mutex);
|
||||||
|
if (route(target,true)<0) return fail(EHOSTUNREACH);
|
||||||
|
Handshake h; h.target=target; h.owner=owner; handshakes[fd]=h;
|
||||||
|
errno=EINPROGRESS;return -1;
|
||||||
|
}
|
||||||
|
bool pending_owner(const void* owner) {
|
||||||
|
if(!owner)return false;
|
||||||
|
std::lock_guard<std::mutex> guard(transport_mutex);
|
||||||
|
for(const auto& [fd,h]:handshakes)if(h.owner==owner)return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool pending(int fd) { std::lock_guard<std::mutex> guard(transport_mutex);return handshakes.count(fd)!=0; }
|
||||||
|
int poll_socket(int fd) {
|
||||||
|
std::lock_guard<std::mutex> guard(transport_mutex);
|
||||||
|
auto it=handshakes.find(fd);if(it==handshakes.end()) return 1;
|
||||||
|
int result=advance(fd,it->second);
|
||||||
|
if (result==1) {
|
||||||
|
handshakes.erase(it);
|
||||||
|
if(!fixture) mt_mesh_connected();else transport_event=2;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
void forget_socket(int fd) {
|
||||||
|
std::lock_guard<std::mutex> guard(transport_mutex);handshakes.erase(fd);
|
||||||
|
auto it=datagrams.find(fd);if(it!=datagrams.end()){close_udp(it->second);datagrams.erase(it);}
|
||||||
|
}
|
||||||
|
ssize_t send_datagram(int fd,const void* bytes,size_t size,int flags,const sockaddr_in& target) {
|
||||||
|
if (same_subnet(address(target.sin_addr.s_addr)))
|
||||||
|
return ::sendto(fd,bytes,size,flags,reinterpret_cast<const sockaddr*>(&target),sizeof(target));
|
||||||
|
if (size>65497) {errno=EMSGSIZE;return -1;}
|
||||||
|
std::lock_guard<std::mutex> guard(transport_mutex);
|
||||||
|
if (route(target,true)<0) return fail(EHOSTUNREACH);
|
||||||
|
if(fcntl(fd,F_GETFD)<0)return -1;
|
||||||
|
auto& udp=datagrams[fd];
|
||||||
|
if (udp.outgoing.size()>=64 || udp.queued_bytes+size>1024*1024) {errno=EWOULDBLOCK;return -1;}
|
||||||
|
const auto* begin=static_cast<const unsigned char*>(bytes);
|
||||||
|
udp.outgoing.push_back({target,{begin,begin+size},Clock::now()+std::chrono::seconds(60)});
|
||||||
|
udp.queued_bytes+=size;pump_udp(fd,udp);return static_cast<ssize_t>(size);
|
||||||
|
}
|
||||||
|
bool datagram_ready(int fd) {
|
||||||
|
std::lock_guard<std::mutex> guard(transport_mutex);
|
||||||
|
auto it=datagrams.find(fd);if(it==datagrams.end())return false;
|
||||||
|
pump_udp(fd,it->second);return !it->second.incoming.empty();
|
||||||
|
}
|
||||||
|
ssize_t recv_datagram(int fd,void* bytes,size_t size,int flags,sockaddr_in* source) {
|
||||||
|
std::lock_guard<std::mutex> guard(transport_mutex);
|
||||||
|
auto it=datagrams.find(fd);
|
||||||
|
if(it!=datagrams.end()) {
|
||||||
|
auto& udp=it->second;pump_udp(fd,udp);
|
||||||
|
if(!udp.incoming.empty()) {
|
||||||
|
auto& packet=udp.incoming.front();
|
||||||
|
const size_t count=std::min(size,packet.bytes.size());
|
||||||
|
std::memcpy(bytes,packet.bytes.data(),count);if(source)*source=packet.target;
|
||||||
|
const auto result=(flags&MSG_TRUNC)?packet.bytes.size():count;
|
||||||
|
if(!(flags&MSG_PEEK))udp.incoming.pop_front();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socklen_t length=sizeof(sockaddr_in);
|
||||||
|
return ::recvfrom(fd,bytes,size,flags,reinterpret_cast<sockaddr*>(source),source?&length:nullptr);
|
||||||
|
}
|
||||||
|
void pump() {
|
||||||
|
std::lock_guard<std::mutex> guard(transport_mutex);
|
||||||
|
for(auto& [fd,udp]:datagrams)pump_udp(fd,udp);
|
||||||
|
}
|
||||||
|
int take_event() { pump();int event=transport_event.exchange(0);int runtime=mt_mesh_event();return event?event:runtime; }
|
||||||
|
|
||||||
|
void shutdown() {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> guard(transport_mutex);
|
||||||
|
for (auto& [fd, udp] : datagrams) {
|
||||||
|
if (udp.control >= 0) ::close(udp.control);
|
||||||
|
if (udp.relay >= 0) ::close(udp.relay);
|
||||||
|
}
|
||||||
|
datagrams.clear(); handshakes.clear(); routes.clear(); proxy = {}; fixture = false;
|
||||||
|
}
|
||||||
|
mt_mesh_shutdown(); transport_event = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
|
||||||
|
namespace mt::meshport {
|
||||||
|
bool in_subnet(uint32_t address, uint32_t local, uint32_t mask);
|
||||||
|
bool same_subnet(const std::string& address);
|
||||||
|
bool contains(const std::string& cidr, const std::string& address);
|
||||||
|
void configure(const std::string& private_profile);
|
||||||
|
// Explicit fixture bridge. Production obtains its routes exclusively from the native runtime.
|
||||||
|
void set_routes(const std::string& listen, const std::vector<std::string>& cidrs);
|
||||||
|
int connect_socket(int fd, const sockaddr_in& target, const void* owner = nullptr);
|
||||||
|
bool pending_owner(const void* owner);
|
||||||
|
int poll_socket(int fd); // 0 waiting; 1 ready; -1 failed. Includes preparation and SOCKS handshake.
|
||||||
|
bool pending(int fd);
|
||||||
|
void forget_socket(int fd);
|
||||||
|
ssize_t send_datagram(int fd, const void* bytes, size_t size, int flags, const sockaddr_in& target);
|
||||||
|
ssize_t recv_datagram(int fd, void* bytes, size_t size, int flags, sockaddr_in* source);
|
||||||
|
bool datagram_ready(int fd);
|
||||||
|
void pump();
|
||||||
|
int take_event(); // 1 preparing; 2 remote transport verified; 3 failed.
|
||||||
|
void shutdown();
|
||||||
|
}
|
||||||
@@ -21,3 +21,7 @@ if(TARGET mtpython)
|
|||||||
set_property(SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/ScriptLib/PythonBoot.cpp
|
set_property(SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/ScriptLib/PythonBoot.cpp
|
||||||
PROPERTY COMPILE_DEFINITIONS ${MT_PORT_DEFINES_UserInterface})
|
PROPERTY COMPILE_DEFINITIONS ${MT_PORT_DEFINES_UserInterface})
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
if(TARGET mt_meshport)
|
||||||
|
target_include_directories(port_platform PRIVATE "${PROJECT_BINARY_DIR}/src/meshport")
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ HRESULT CpuSurface::LockRect(D3DLOCKED_RECT* locked, const RECT* rect, DWORD fla
|
|||||||
return S_OK;
|
return S_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PORT: a 32-bit render-target texture (the AVA2 model preview's CGraphicRenderTargetTexture), drawn on the
|
||||||
|
// GPU with full state; the R5G6B5 ones are the character shadow map.
|
||||||
|
bool is_color_target(const D3DSURFACE_DESC& desc)
|
||||||
|
{
|
||||||
|
return (desc.Usage & D3DUSAGE_RENDERTARGET) && (desc.Format == D3DFMT_X8R8G8B8 || desc.Format == D3DFMT_A8R8G8B8);
|
||||||
|
}
|
||||||
|
|
||||||
D3DSURFACE_DESC surface_desc(UINT width, UINT height, D3DFORMAT format, DWORD usage, D3DPOOL pool)
|
D3DSURFACE_DESC surface_desc(UINT width, UINT height, D3DFORMAT format, DWORD usage, D3DPOOL pool)
|
||||||
{
|
{
|
||||||
D3DSURFACE_DESC desc = {};
|
D3DSURFACE_DESC desc = {};
|
||||||
@@ -454,7 +461,10 @@ public:
|
|||||||
return S_OK;
|
return S_OK;
|
||||||
}
|
}
|
||||||
auto* surface = static_cast<CpuSurface*>(m_renderTarget);
|
auto* surface = static_cast<CpuSurface*>(m_renderTarget);
|
||||||
if (!surface->parent || surface->level != 0 || surface->desc.Format != D3DFMT_R5G6B5)
|
const bool colorTarget = surface->parent && surface->level == 0 && is_color_target(surface->desc);
|
||||||
|
if (colorTarget && !g_gpu_render_targets)
|
||||||
|
return S_OK;
|
||||||
|
if (!colorTarget && (!surface->parent || surface->level != 0 || surface->desc.Format != D3DFMT_R5G6B5))
|
||||||
return S_OK;
|
return S_OK;
|
||||||
if (g_gpu_render_targets)
|
if (g_gpu_render_targets)
|
||||||
{
|
{
|
||||||
@@ -463,6 +473,7 @@ public:
|
|||||||
clear.render_target = surface->parent->id;
|
clear.render_target = surface->parent->id;
|
||||||
clear.target_width = surface->desc.Width;
|
clear.target_width = surface->desc.Width;
|
||||||
clear.target_height = surface->desc.Height;
|
clear.target_height = surface->desc.Height;
|
||||||
|
clear.target_color = colorTarget;
|
||||||
clear.clear_flags = flags & (D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER);
|
clear.clear_flags = flags & (D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER);
|
||||||
clear.clear_color = color;
|
clear.clear_color = color;
|
||||||
clear.clear_z = depth;
|
clear.clear_z = depth;
|
||||||
@@ -1140,11 +1151,30 @@ private:
|
|||||||
if (m_renderTarget == m_backBuffer)
|
if (m_renderTarget == m_backBuffer)
|
||||||
Render3DAdd(std::move(draw));
|
Render3DAdd(std::move(draw));
|
||||||
else if (g_gpu_render_targets)
|
else if (g_gpu_render_targets)
|
||||||
record_shadow(std::move(draw));
|
{
|
||||||
|
auto* surface = static_cast<CpuSurface*>(m_renderTarget);
|
||||||
|
if (surface->parent && surface->level == 0 && is_color_target(surface->desc))
|
||||||
|
record_color(std::move(draw), *surface);
|
||||||
|
else
|
||||||
|
record_shadow(std::move(draw));
|
||||||
|
}
|
||||||
else
|
else
|
||||||
rasterize_shadow(draw);
|
rasterize_shadow(draw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PORT: a draw into a colour render target keeps every state the back buffer would get.
|
||||||
|
void record_color(Render3DDraw draw, const CpuSurface& surface)
|
||||||
|
{
|
||||||
|
if (!draw.shared_geometry && draw.indices.size() < (draw.lines ? 2u : 3u))
|
||||||
|
return;
|
||||||
|
draw.render_target = surface.parent->id;
|
||||||
|
draw.target_width = surface.desc.Width;
|
||||||
|
draw.target_height = surface.desc.Height;
|
||||||
|
draw.target_color = true;
|
||||||
|
draw.ui_offset_x = 0;
|
||||||
|
Render3DAdd(std::move(draw));
|
||||||
|
}
|
||||||
|
|
||||||
// GPU counterpart of rasterize_shadow: the draw goes to the renderer's offscreen pass with the
|
// GPU counterpart of rasterize_shadow: the draw goes to the renderer's offscreen pass with the
|
||||||
// states rasterize_shadow implements -- a flat D3DRS_TEXTUREFACTOR fill, no culling, no texture,
|
// states rasterize_shadow implements -- a flat D3DRS_TEXTUREFACTOR fill, no culling, no texture,
|
||||||
// light, fog or blending, depth test LESSEQUAL with depth writes -- so both paths draw the same map.
|
// light, fog or blending, depth test LESSEQUAL with depth writes -- so both paths draw the same map.
|
||||||
@@ -1257,6 +1287,8 @@ std::string MtCpuTextureNameFromHandle(const IDirect3DBaseTexture8* handle)
|
|||||||
const D3DSURFACE_DESC& desc = entry.second->levels[0].desc;
|
const D3DSURFACE_DESC& desc = entry.second->levels[0].desc;
|
||||||
if (g_gpu_render_targets && (desc.Usage & D3DUSAGE_RENDERTARGET) && desc.Format == D3DFMT_R5G6B5)
|
if (g_gpu_render_targets && (desc.Usage & D3DUSAGE_RENDERTARGET) && desc.Format == D3DFMT_R5G6B5)
|
||||||
return Render3DRenderTargetName(entry.first, desc.Width, desc.Height);
|
return Render3DRenderTargetName(entry.first, desc.Width, desc.Height);
|
||||||
|
if (g_gpu_render_targets && is_color_target(desc))
|
||||||
|
return Render3DRenderTargetName(entry.first, desc.Width, desc.Height, true);
|
||||||
if (entry.second == handle && !entry.second->levels.empty())
|
if (entry.second == handle && !entry.second->levels.empty())
|
||||||
return "mem:cpu_" + std::to_string(entry.first) + "@" + std::to_string(entry.second->revision);
|
return "mem:cpu_" + std::to_string(entry.first) + "@" + std::to_string(entry.second->revision);
|
||||||
}
|
}
|
||||||
@@ -1406,9 +1438,9 @@ bool IsNativeTerrainRenderEnabled()
|
|||||||
void SetGpuRenderTargetsEnabled(bool enabled) { g_gpu_render_targets = enabled; }
|
void SetGpuRenderTargetsEnabled(bool enabled) { g_gpu_render_targets = enabled; }
|
||||||
bool IsGpuRenderTargetsEnabled() { return g_gpu_render_targets; }
|
bool IsGpuRenderTargetsEnabled() { return g_gpu_render_targets; }
|
||||||
|
|
||||||
std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height)
|
std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height, bool color)
|
||||||
{
|
{
|
||||||
return "rt:" + std::to_string(id) + ":" + std::to_string(width) + "x" + std::to_string(height);
|
return "rt:" + std::to_string(id) + ":" + std::to_string(width) + "x" + std::to_string(height) + (color ? ":c" : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
void Render3DBeginFrame()
|
void Render3DBeginFrame()
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ struct Render3DDraw {
|
|||||||
// viewport is then in the target's pixels. Only emitted while GPU render targets are enabled.
|
// viewport is then in the target's pixels. Only emitted while GPU render targets are enabled.
|
||||||
std::uint32_t render_target = 0;
|
std::uint32_t render_target = 0;
|
||||||
std::uint32_t target_width = 0, target_height = 0;
|
std::uint32_t target_width = 0, target_height = 0;
|
||||||
|
// PORT: an X8R8G8B8 / A8R8G8B8 colour target (the AVA2 model preview, CRenderTarget) drawn with the
|
||||||
|
// draw's own states, named with Render3DRenderTargetName(..., true); otherwise the R5G6B5 shadow map.
|
||||||
|
bool target_color = false;
|
||||||
|
|
||||||
// Stage 0/1 textures, named like UIRenderTextureName: the pack path of a file texture or
|
// Stage 0/1 textures, named like UIRenderTextureName: the pack path of a file texture or
|
||||||
// "mem:<id>@<revision>"; empty when the stage has no texture.
|
// "mem:<id>@<revision>"; empty when the stage has no texture.
|
||||||
@@ -143,7 +146,8 @@ bool LookupGpuSkinSubrange(const void* vertex_buffer_base, std::uint32_t stride,
|
|||||||
// default (the Godot consumer has no offscreen pass); the native Vulkan renderer turns it on.
|
// default (the Godot consumer has no offscreen pass); the native Vulkan renderer turns it on.
|
||||||
void SetGpuRenderTargetsEnabled(bool enabled);
|
void SetGpuRenderTargetsEnabled(bool enabled);
|
||||||
bool IsGpuRenderTargetsEnabled();
|
bool IsGpuRenderTargetsEnabled();
|
||||||
std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height);
|
// A colour target's name ends in ":c".
|
||||||
|
std::string Render3DRenderTargetName(std::uint32_t id, std::uint32_t width, std::uint32_t height, bool color = false);
|
||||||
|
|
||||||
void Render3DBeginFrame();
|
void Render3DBeginFrame();
|
||||||
void Render3DAdd(Render3DDraw draw);
|
void Render3DAdd(Render3DDraw draw);
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
// PORT: battle pass (战令), from AVA2's uibattlepassex.py. The server runs monthly seasons of up to ten missions
|
||||||
|
// counted from real events (kills, fishing, refines, levels, NPC trades, play time); each mission has a reward and
|
||||||
|
// a final reward follows once all are done. This window asks with /mt_bp, shows progress live while open and
|
||||||
|
// claims rewards. Entry points: F6 and a "战令" button by the minimap. Protocol: metin2-server
|
||||||
|
// docs/FEATURE-BATTLEPASS.md.
|
||||||
|
static const char c_szBattlePassScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||||
|
import app
|
||||||
|
import chat
|
||||||
|
import game
|
||||||
|
import grp
|
||||||
|
import item
|
||||||
|
import net
|
||||||
|
import nonplayer
|
||||||
|
import player
|
||||||
|
import ui
|
||||||
|
import weakref
|
||||||
|
from mt_biolog import Action, Text, L
|
||||||
|
|
||||||
|
g_window=None
|
||||||
|
g_entry=None
|
||||||
|
SEND_GAP=.4
|
||||||
|
TIMEOUT=3.
|
||||||
|
PAGE=5
|
||||||
|
ROW=56
|
||||||
|
WIDTH=620
|
||||||
|
MAX_SLOT=10
|
||||||
|
# type: (name without sub, name with a sub vnum: kind of the vnum)
|
||||||
|
TYPES={1:('击杀怪物','击杀 ','mob'),2:('击杀 Boss','击杀 Boss ','mob'),3:('摧毁陨石','摧毁 ','mob'),4:('累计在线',None,None),
|
||||||
|
5:('钓鱼','钓到 ','item'),6:('成功强化装备','强化出 ','item'),7:('提升等级',None,None),8:('在 NPC 商店消费金币',None,None),
|
||||||
|
9:(None,'向 NPC 出售 ','item')}
|
||||||
|
ERRORS={'loading':'数据加载中,请稍后再试','slot':'没有这个任务','claimed':'奖励已经领取过了','incomplete':'任务尚未完成',
|
||||||
|
'space':'背包空间不足,请整理后再领取','config':'奖励配置有误,请联系管理员','db':'服务器繁忙,请稍后再试',
|
||||||
|
'rate':'操作过快,请稍后再试','invalid':'参数无效','ticket':'请使用当月战令票激活','month':'这张战令票不属于当前月份',
|
||||||
|
'activated':'本月战令已经激活过了','busy':'请先结束交易、商店、仓库或传送'}
|
||||||
|
|
||||||
|
|
||||||
|
def Number(text,low=0,high=10**12):
|
||||||
|
if not text.isdigit() or len(text)>13:raise ValueError(text)
|
||||||
|
value=int(text)
|
||||||
|
if not low<=value<=high:raise ValueError(text)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def Rewards(words):
|
||||||
|
if len(words)%2:raise ValueError(words)
|
||||||
|
return [(Number(words[i],1,2**32-1),Number(words[i+1],1,10000)) for i in range(0,len(words),2)]
|
||||||
|
|
||||||
|
|
||||||
|
def Grouped(value):
|
||||||
|
text=str(int(value));parts=[]
|
||||||
|
while len(text)>3:parts.insert(0,text[-3:]);text=text[:-3]
|
||||||
|
return ','.join([text]+parts)
|
||||||
|
|
||||||
|
|
||||||
|
def Amount(kind,value,target):
|
||||||
|
# Play time is counted in seconds and shown in hours, or in minutes for a target under an hour.
|
||||||
|
if kind==4:
|
||||||
|
unit=3600. if target>=3600 else 60.
|
||||||
|
amount=value/unit
|
||||||
|
return ('%d'%amount) if amount==int(amount) else ('%.1f'%amount)
|
||||||
|
return Grouped(value)
|
||||||
|
|
||||||
|
|
||||||
|
def Unit(kind,target):
|
||||||
|
if kind!=4:return ''
|
||||||
|
return ' 小时' if target>=3600 else ' 分钟'
|
||||||
|
|
||||||
|
|
||||||
|
def SeasonName(season):
|
||||||
|
year,month=divmod(season,100)
|
||||||
|
if 2000<=year<=2999 and 1<=month<=12:return '%d 年 %d 月'%(year,month)
|
||||||
|
return '第 %d 期'%season
|
||||||
|
|
||||||
|
|
||||||
|
def Left(seconds):
|
||||||
|
seconds=int(seconds)
|
||||||
|
if seconds<=0:return '已结束'
|
||||||
|
days,rest=divmod(seconds,86400);hours,rest=divmod(rest,3600)
|
||||||
|
if days:return '剩余 %d 天 %d 小时'%(days,hours)
|
||||||
|
return '剩余 %d 小时 %d 分'%(hours,max(1,rest//60))
|
||||||
|
|
||||||
|
|
||||||
|
class Pass(object):
|
||||||
|
# Protocol state only. send(text) posts a chat command, now() is seconds.
|
||||||
|
def __init__(self,send,now,listener=None):
|
||||||
|
self.send=send;self.now=now;self.listener=listener
|
||||||
|
self.waiting=None;self.nextSend=0;self.unsupported=False
|
||||||
|
self.Clear()
|
||||||
|
|
||||||
|
def Clear(self):
|
||||||
|
self.season=None;self.missions={};self.final=None;self.reading=None;self.none=False;self.error=None;self.claiming=None;self.access=None;self.month=None
|
||||||
|
|
||||||
|
def Ready(self):
|
||||||
|
return self.waiting is None or self.now()>=self.waiting
|
||||||
|
|
||||||
|
def Post(self,text,expect=True):
|
||||||
|
now=self.now()
|
||||||
|
if now<self.nextSend:return False
|
||||||
|
self.send(text);self.nextSend=now+SEND_GAP
|
||||||
|
if expect:self.waiting=now+TIMEOUT
|
||||||
|
return True
|
||||||
|
|
||||||
|
def Query(self):
|
||||||
|
self.Tick()
|
||||||
|
if self.waiting is not None:return False
|
||||||
|
return self.Post('/mt_bp 1 info')
|
||||||
|
|
||||||
|
def Claim(self,slot):
|
||||||
|
if self.claiming is not None or not self.season or self.access not in (None,1):return False
|
||||||
|
if self.Post('/mt_bp 1 claim %d'%slot):self.claiming=slot;self.error=None;return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def Activate(self,cell,vnum):
|
||||||
|
if self.access!=0 or self.waiting is not None or not 25150<=vnum<=25161:return False
|
||||||
|
if player.GetItemIndex(cell)!=vnum:return False
|
||||||
|
return self.Post('/mt_bp 1 activate %d %d'%(cell,vnum))
|
||||||
|
|
||||||
|
def Close(self):
|
||||||
|
self.send('/mt_bp 1 close')
|
||||||
|
|
||||||
|
def Tick(self):
|
||||||
|
if self.waiting is not None and self.now()>=self.waiting:
|
||||||
|
self.waiting=None;self.claiming=None
|
||||||
|
if self.season is None:self.unsupported=True
|
||||||
|
self.Notify('timeout')
|
||||||
|
|
||||||
|
def SecondsLeft(self):
|
||||||
|
if not self.season:return 0
|
||||||
|
_,_,end,now,at=self.season
|
||||||
|
return end-(now+(self.now()-at))
|
||||||
|
|
||||||
|
def Notify(self,kind,value=None):
|
||||||
|
if self.listener:self.listener(kind,value)
|
||||||
|
|
||||||
|
def Receive(self,line):
|
||||||
|
if not line.startswith('MTBP1 '):return False
|
||||||
|
words=line.split()
|
||||||
|
try:self.Parse(words[1],words[2:])
|
||||||
|
except (ValueError,IndexError):self.reading=None
|
||||||
|
return True
|
||||||
|
|
||||||
|
def Parse(self,kind,args):
|
||||||
|
if kind=='season' and len(args)==5:
|
||||||
|
seasonId,start,end,now=[Number(w,0,2**32-1) for w in args[:4]];count=Number(args[4],1,MAX_SLOT)
|
||||||
|
self.reading=dict(season=(seasonId,start,end,now,self.now()),count=count,missions={},final=None,access=None,month=None)
|
||||||
|
elif kind=='access' and len(args) in (1,2) and self.reading is not None:
|
||||||
|
self.reading['access']=Number(args[0],0,2)
|
||||||
|
if len(args)==2:self.reading['month']=Number(args[1],1,12)
|
||||||
|
elif kind=='mission' and self.reading is not None and len(args)>=6:
|
||||||
|
slot=Number(args[0],1,MAX_SLOT);kind=Number(args[1],1,9)
|
||||||
|
self.reading['missions'][slot]=dict(slot=slot,type=kind,sub=Number(args[2],0,2**32-1),target=Number(args[3],1),
|
||||||
|
value=Number(args[4]),claimed=Number(args[5],0,1)==1,rewards=Rewards(args[6:]))
|
||||||
|
elif kind=='final' and self.reading is not None and len(args)>=4:
|
||||||
|
self.reading['final']=dict(claimed=Number(args[0],0,1)==1,all=Number(args[1],0,1)==1,rewards=Rewards(args[2:]))
|
||||||
|
elif kind=='end' and not args and self.reading is not None:
|
||||||
|
r=self.reading;self.reading=None
|
||||||
|
if len(r['missions'])!=r['count'] or r['final'] is None:return
|
||||||
|
self.season=r['season'];self.missions=r['missions'];self.final=r['final'];self.access=r['access'];self.month=r['month'];self.none=False
|
||||||
|
self.Answered('info')
|
||||||
|
elif kind=='none' and not args:
|
||||||
|
self.Clear();self.none=True;self.Answered('none')
|
||||||
|
elif kind=='progress' and len(args)==2:
|
||||||
|
slot=Number(args[0],1,MAX_SLOT);m=self.missions.get(slot)
|
||||||
|
if m:m['value']=min(m['target'],Number(args[1]));self.Notify('progress',slot)
|
||||||
|
elif kind=='done' and len(args)==1:
|
||||||
|
slot=Number(args[0],1,MAX_SLOT);m=self.missions.get(slot)
|
||||||
|
if m:m['value']=m['target']
|
||||||
|
self.Notify('done',slot)
|
||||||
|
elif kind=='claimed' and len(args)==1:
|
||||||
|
slot=Number(args[0],0,MAX_SLOT)
|
||||||
|
if slot:
|
||||||
|
if slot in self.missions:self.missions[slot]['claimed']=True
|
||||||
|
elif self.final:self.final['claimed']=True
|
||||||
|
self.claiming=None;self.Notify('claimed',slot) # a full info follows
|
||||||
|
elif kind=='error' and len(args)==1 and args[0] in ERRORS:
|
||||||
|
self.error=args[0];self.claiming=None;self.Answered('error')
|
||||||
|
|
||||||
|
def Answered(self,kind):
|
||||||
|
self.waiting=None;self.unsupported=False;self.Notify(kind)
|
||||||
|
|
||||||
|
def AllDone(self):
|
||||||
|
return bool(self.missions) and all(m['value']>=m['target'] for m in self.missions.values())
|
||||||
|
|
||||||
|
|
||||||
|
def ItemName(vnum):
|
||||||
|
try:
|
||||||
|
item.SelectItem(vnum);name=item.GetItemName()
|
||||||
|
except Exception:name=''
|
||||||
|
return name if name else L('物品 %d'%vnum)
|
||||||
|
|
||||||
|
|
||||||
|
def MobName(vnum):
|
||||||
|
try:name=nonplayer.GetMonsterName(vnum)
|
||||||
|
except Exception:name=''
|
||||||
|
return name if name else L('怪物 %d'%vnum)
|
||||||
|
|
||||||
|
|
||||||
|
def MissionName(m):
|
||||||
|
# Locale text: the utf-8 parts go through L, game names are already in the locale's code page.
|
||||||
|
plain,prefix,kind=TYPES.get(m['type'],('任务',None,None))
|
||||||
|
if m['sub'] and prefix:return L(prefix)+(MobName(m['sub']) if kind=='mob' else ItemName(m['sub']))
|
||||||
|
return L(plain or '任务')
|
||||||
|
|
||||||
|
|
||||||
|
def RewardText(rewards):
|
||||||
|
return L(',').join([ItemName(vnum)+(' x%d'%count) for vnum,count in rewards])
|
||||||
|
|
||||||
|
|
||||||
|
class BattlePassWindow(ui.Window):
|
||||||
|
def __init__(self,state):
|
||||||
|
ui.Window.__init__(self);self.SetWindowName('BattlePassWindow');self.AddFlag('float');self.AddFlag('movable')
|
||||||
|
self.state=state;self.page=0
|
||||||
|
self.SetSize(WIDTH,74+PAGE*ROW+ROW+20+56)
|
||||||
|
self.heading=Text(self,18,18,'战令',0xffffd68b)
|
||||||
|
self.season=Text(self,80,18,'',0xffc3b7a1)
|
||||||
|
self.closeButton=Action(self,WIDTH-76,6,64,'关闭',self.Hide)
|
||||||
|
self.rows=[]
|
||||||
|
for i in range(PAGE):
|
||||||
|
y=64+i*ROW
|
||||||
|
self.rows.append(dict(name=Text(self,18,y+4,''),value=Text(self,330,y+4,''),
|
||||||
|
reward=Text(self,18,y+26,'',0xffb3aa98),button=Action(self,WIDTH-118,y,100,'领取',lambda i=i:self.ClaimRow(i))))
|
||||||
|
y=64+PAGE*ROW+8
|
||||||
|
self.finalName=Text(self,18,y+4,'最终奖励',0xffffd68b)
|
||||||
|
self.finalState=Text(self,330,y+4,'')
|
||||||
|
self.finalReward=Text(self,18,y+26,'',0xffb3aa98)
|
||||||
|
self.finalButton=Action(self,WIDTH-118,y,100,'领取',lambda:self.state.Claim(0) and self.Refresh())
|
||||||
|
self.activateButton=Action(self,WIDTH-218,y,200,'使用当月战令票激活',self.AskActivate)
|
||||||
|
self.activateButton.Hide();self.confirm=None;self.pendingTicket=None
|
||||||
|
y=self.GetHeight()-56
|
||||||
|
self.prev=Action(self,18,y,80,'上一页',lambda:self.Turn(-1))
|
||||||
|
self.pageText=Text(self,140,y+24,'');self.pageText.SetHorizontalAlignCenter()
|
||||||
|
self.next=Action(self,182,y,80,'下一页',lambda:self.Turn(1))
|
||||||
|
self.note=Text(self,280,y+16,'',0xffc3b7a1)
|
||||||
|
self.second=-1
|
||||||
|
self.Hide()
|
||||||
|
|
||||||
|
def Open(self):
|
||||||
|
self.state.error=None;self.state.Query();self.Refresh();self.SetCenterPosition();ui.Window.Show(self);self.SetTop()
|
||||||
|
|
||||||
|
def Toggle(self):
|
||||||
|
if self.IsShow():self.Hide()
|
||||||
|
else:self.Open()
|
||||||
|
|
||||||
|
def Hide(self):
|
||||||
|
state=self.__dict__.get('state') # ui.Window.__init__ hides the window before state is set
|
||||||
|
if self.IsShow() and state and state.season:state.Close()
|
||||||
|
self.pendingTicket=None
|
||||||
|
self.CloseConfirm()
|
||||||
|
ui.Window.Hide(self)
|
||||||
|
|
||||||
|
def CloseConfirm(self):
|
||||||
|
confirm=self.__dict__.get('confirm')
|
||||||
|
if confirm:
|
||||||
|
confirm.Close();confirm.SetAcceptEvent(None);confirm.SetCancelEvent(None);self.confirm=None
|
||||||
|
|
||||||
|
def OfferTicket(self,cell,vnum):
|
||||||
|
self.pendingTicket=(cell,vnum)
|
||||||
|
self.Open()
|
||||||
|
|
||||||
|
def AskTicket(self,cell,vnum):
|
||||||
|
if self.state.access!=0 or player.GetItemIndex(cell)!=vnum:return
|
||||||
|
item.SelectItem(vnum)
|
||||||
|
if item.GetValue(0)!=self.state.month:self.state.error='month';self.Refresh();return
|
||||||
|
import uiCommon
|
||||||
|
self.CloseConfirm();self.confirm=uiCommon.QuestionDialog()
|
||||||
|
self.confirm.SetText(L('消耗战令票并激活本月战令?'))
|
||||||
|
self.confirm.SetAcceptEvent(lambda:self.ConfirmActivate(cell,vnum))
|
||||||
|
self.confirm.SetCancelEvent(self.CloseConfirm);self.confirm.Open()
|
||||||
|
|
||||||
|
def AskActivate(self):
|
||||||
|
if self.state.access!=0:return
|
||||||
|
for cell in range(player.INVENTORY_PAGE_SIZE*player.INVENTORY_PAGE_COUNT):
|
||||||
|
vnum=player.GetItemIndex(cell)
|
||||||
|
if not 25150<=vnum<=25161:continue
|
||||||
|
item.SelectItem(vnum)
|
||||||
|
# Display uses server time, not the phone's local month/time zone.
|
||||||
|
if item.GetValue(0)!=self.state.month:continue
|
||||||
|
self.AskTicket(cell,vnum);return
|
||||||
|
self.state.error='ticket';self.Refresh()
|
||||||
|
|
||||||
|
def ConfirmActivate(self,cell,vnum):
|
||||||
|
self.CloseConfirm();self.state.Activate(cell,vnum);self.Refresh()
|
||||||
|
|
||||||
|
def Slots(self):
|
||||||
|
return sorted(self.state.missions)
|
||||||
|
|
||||||
|
def Pages(self):
|
||||||
|
return max(1,(len(self.Slots())+PAGE-1)//PAGE)
|
||||||
|
|
||||||
|
def Turn(self,delta):
|
||||||
|
self.page=max(0,min(self.Pages()-1,self.page+delta));self.Refresh()
|
||||||
|
|
||||||
|
def ClaimRow(self,i):
|
||||||
|
slots=self.Slots()[self.page*PAGE:]
|
||||||
|
if i<len(slots) and self.state.Claim(slots[i]):self.Refresh()
|
||||||
|
|
||||||
|
def Refresh(self):
|
||||||
|
s=self.state;self.page=min(self.page,self.Pages()-1)
|
||||||
|
slots=self.Slots()[self.page*PAGE:(self.page+1)*PAGE]
|
||||||
|
for i,row in enumerate(self.rows):
|
||||||
|
widgets=(row['name'],row['value'],row['reward'],row['button'])
|
||||||
|
if i>=len(slots):
|
||||||
|
for w in widgets:w.Hide()
|
||||||
|
continue
|
||||||
|
for w in widgets:w.Show()
|
||||||
|
m=s.missions[slots[i]];done=m['value']>=m['target']
|
||||||
|
row['name'].SetText(MissionName(m))
|
||||||
|
row['value'].SetText(Amount(m['type'],m['value'],m['target'])+' / '+Amount(m['type'],m['target'],m['target'])+L(Unit(m['type'],m['target'])))
|
||||||
|
row['value'].SetPackedFontColor(0xff9fe08b if done else 0xffe8dec8)
|
||||||
|
row['reward'].SetText(L('奖励:')+RewardText(m['rewards']) if m['rewards'] else L('无奖励'))
|
||||||
|
if m['claimed']:row['button'].SetText('已领取')
|
||||||
|
elif s.claiming==m['slot']:row['button'].SetText('领取中')
|
||||||
|
else:row['button'].SetText('领取' if done else '未完成')
|
||||||
|
row['button'].SetEnabled(s.access in (None,1) and done and not m['claimed'] and bool(m['rewards']) and s.claiming is None)
|
||||||
|
f=s.final
|
||||||
|
for w in (self.finalName,self.finalState,self.finalReward,self.finalButton):
|
||||||
|
if f:w.Show()
|
||||||
|
else:w.Hide()
|
||||||
|
if f:
|
||||||
|
ready=s.AllDone()
|
||||||
|
self.finalState.SetText(L('已领取' if f['claimed'] else '全部任务完成' if ready else '完成全部任务后可领取'))
|
||||||
|
self.finalReward.SetText(L('奖励:')+RewardText(f['rewards']))
|
||||||
|
self.finalButton.SetText('已领取' if f['claimed'] else '领取中' if s.claiming==0 else '领取')
|
||||||
|
self.finalButton.SetEnabled(s.access in (None,1) and ready and not f['claimed'] and s.claiming is None)
|
||||||
|
if s.access==0:
|
||||||
|
self.finalButton.Hide();self.finalState.Hide();self.finalReward.Hide();self.activateButton.Show()
|
||||||
|
self.activateButton.SetEnabled(s.waiting is None)
|
||||||
|
else:self.activateButton.Hide()
|
||||||
|
many=self.Pages()>1
|
||||||
|
for w in (self.prev,self.next,self.pageText):
|
||||||
|
if many and s.season:w.Show()
|
||||||
|
else:w.Hide()
|
||||||
|
self.prev.SetEnabled(self.page>0);self.next.SetEnabled(self.page<self.Pages()-1)
|
||||||
|
self.pageText.SetText('%d / %d'%(self.page+1,self.Pages()))
|
||||||
|
if s.season:self.season.SetText(L(SeasonName(s.season[0])+' '+Left(s.SecondsLeft())))
|
||||||
|
else:self.season.SetText('')
|
||||||
|
if s.unsupported:note='!服务器尚未启用战令'
|
||||||
|
elif s.error:note='!'+ERRORS[s.error]
|
||||||
|
elif s.none:note='当前没有进行中的战令赛季'
|
||||||
|
elif not s.season:note='正在查询……'
|
||||||
|
elif s.claiming is not None:note='正在领取……'
|
||||||
|
elif s.access==0:note='激活后才开始累计进度'
|
||||||
|
elif s.access==2:note='本月战令已完成'
|
||||||
|
else:note='击杀、钓鱼、强化等都会自动计入进度'
|
||||||
|
self.note.SetText(L(note))
|
||||||
|
|
||||||
|
def OnUpdate(self):
|
||||||
|
self.state.Tick()
|
||||||
|
second=int(app.GetTime())
|
||||||
|
if second!=self.second:self.second=second;self.Refresh()
|
||||||
|
|
||||||
|
def OnRender(self):
|
||||||
|
x,y=self.GetGlobalPosition();w=self.GetWidth()
|
||||||
|
grp.SetColor(0xf51b1916);grp.RenderBar(x,y,w,self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,w,self.GetHeight())
|
||||||
|
grp.SetColor(0xff4a3d2a)
|
||||||
|
for i in range(PAGE+1):grp.RenderLine(x+12,y+60+i*ROW,w-24,0)
|
||||||
|
|
||||||
|
def OnPressEscapeKey(self):self.Hide();return True
|
||||||
|
|
||||||
|
def Destroy(self):
|
||||||
|
self.Hide();self.closeButton.event=self.finalButton.event=self.activateButton.event=self.prev.event=self.next.event=None
|
||||||
|
for row in self.rows:row['button'].event=None
|
||||||
|
self.state=None
|
||||||
|
|
||||||
|
|
||||||
|
class Entry(Action):
|
||||||
|
def __init__(self,window):
|
||||||
|
Action.__init__(self,None,0,0,64,'战令',Toggle)
|
||||||
|
self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('BattlePassEntry');self.OnUpdate()
|
||||||
|
|
||||||
|
def OnUpdate(self):
|
||||||
|
x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+460)
|
||||||
|
|
||||||
|
|
||||||
|
def OnPass(kind,value):
|
||||||
|
s=g_pass
|
||||||
|
if kind=='done':
|
||||||
|
m=s.missions.get(value)
|
||||||
|
name=MissionName(m) if m else L('一项任务')
|
||||||
|
chat.AppendChat(chat.CHAT_TYPE_INFO,L('战令任务完成:')+name+L(',按 F6 领取奖励'))
|
||||||
|
elif kind=='claimed':chat.AppendChat(chat.CHAT_TYPE_INFO,L('战令奖励已放入背包' if value else '战令最终奖励已放入背包'))
|
||||||
|
elif kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[s.error]))
|
||||||
|
if g_window and g_window.IsShow():
|
||||||
|
if kind=='info' and g_window.pendingTicket:
|
||||||
|
ticket=g_window.pendingTicket;g_window.pendingTicket=None;g_window.AskTicket(*ticket)
|
||||||
|
g_window.Refresh()
|
||||||
|
|
||||||
|
|
||||||
|
g_pass=Pass(net.SendChatPacket,app.GetTime,OnPass)
|
||||||
|
|
||||||
|
|
||||||
|
def Toggle():
|
||||||
|
if g_window:g_window.Toggle()
|
||||||
|
|
||||||
|
|
||||||
|
_open=game.GameWindow.Open
|
||||||
|
_close=game.GameWindow.Close
|
||||||
|
_command=game.GameWindow.BINARY_ServerCommand_Run
|
||||||
|
|
||||||
|
def Open(self):
|
||||||
|
global g_window,g_entry
|
||||||
|
_open(self);g_pass.waiting=None;g_pass.unsupported=False;g_pass.Clear()
|
||||||
|
g_window=BattlePassWindow(g_pass);g_entry=Entry(self)
|
||||||
|
keys=getattr(self,'onPressKeyDict',None)
|
||||||
|
if keys is not None and app.DIK_F6 not in keys:keys[app.DIK_F6]=lambda:Toggle()
|
||||||
|
|
||||||
|
def Close(self):
|
||||||
|
global g_window,g_entry
|
||||||
|
if g_entry:g_entry.Hide();g_entry.event=None;g_entry=None
|
||||||
|
if g_window:g_window.Destroy();g_window=None
|
||||||
|
_close(self)
|
||||||
|
|
||||||
|
def Command(self,line):
|
||||||
|
if g_pass.Receive(line):return 1
|
||||||
|
return _command(self,line)
|
||||||
|
|
||||||
|
game.GameWindow.Open=Open
|
||||||
|
game.GameWindow.Close=Close
|
||||||
|
game.GameWindow.BINARY_ServerCommand_Run=Command
|
||||||
|
)PY";
|
||||||
@@ -24,6 +24,20 @@ g_tick=None
|
|||||||
PAGE=8
|
PAGE=8
|
||||||
REFRESH=30.
|
REFRESH=30.
|
||||||
SHARED_CHANNEL=99
|
SHARED_CHANNEL=99
|
||||||
|
TRAVEL_LEVELS={9844:(40,60),9836:(65,85),9838:(80,100),9840:(95,115),9842:(105,120),
|
||||||
|
1093:(40,120),2092:(70,120),2493:(75,120),2598:(80,120),6091:(90,120),
|
||||||
|
6191:(90,120),9018:(105,120),20442:(110,120)}
|
||||||
|
|
||||||
|
|
||||||
|
def Travel(vnum,done=None):
|
||||||
|
service=mt_feature_service.g_service
|
||||||
|
if not service:return
|
||||||
|
def received(data,error):
|
||||||
|
errors={'unsupported':'服务器未启用此入口传送','level':'等级不符合入口要求',
|
||||||
|
'busy':'当前状态不能传送,请稍后再试','unavailable':'服务器尚未开放该地图'}
|
||||||
|
chat.AppendChat(chat.CHAT_TYPE_INFO,L(errors.get(error,'传送失败,请稍后重试') if error else '正在前往入口'))
|
||||||
|
if done:done(data,error)
|
||||||
|
service.Request('travel',vnum,0,received)
|
||||||
# Server map index -> map directory (share/locale/<locale>/map/index), for localeInfo.MINIMAP_ZONE_NAME_DICT.
|
# Server map index -> map directory (share/locale/<locale>/map/index), for localeInfo.MINIMAP_ZONE_NAME_DICT.
|
||||||
MAPS={1:'metin2_map_a1',3:'metin2_map_a3',4:'metin2_map_guild_01',5:'metin2_map_monkey_dungeon_11',
|
MAPS={1:'metin2_map_a1',3:'metin2_map_a3',4:'metin2_map_guild_01',5:'metin2_map_monkey_dungeon_11',
|
||||||
6:'metin2_guild_village_01',21:'metin2_map_b1',23:'metin2_map_b3',24:'metin2_map_guild_02',
|
6:'metin2_guild_village_01',21:'metin2_map_b1',23:'metin2_map_b3',24:'metin2_map_guild_02',
|
||||||
@@ -117,10 +131,11 @@ class BossWindow(ui.Window):
|
|||||||
self.lines=[Text(self,18,0,'',0xffefdfc3) for i in range(PAGE)]
|
self.lines=[Text(self,18,0,'',0xffefdfc3) for i in range(PAGE)]
|
||||||
self.details=[Text(self,30,0,'',0xffb2aa9a) for i in range(PAGE)]
|
self.details=[Text(self,30,0,'',0xffb2aa9a) for i in range(PAGE)]
|
||||||
self.remindButtons=[Action(self,0,0,92,'',lambda i=i:self.Remind(i)) for i in range(PAGE)]
|
self.remindButtons=[Action(self,0,0,92,'',lambda i=i:self.Remind(i)) for i in range(PAGE)]
|
||||||
|
self.travelButtons=[Action(self,0,0,72,'传送',lambda i=i:self.Teleport(i)) for i in range(PAGE)]
|
||||||
self.note=Text(self,18,0,'提醒:Boss 刷新后在聊天框提示;以服务器刷新为准。',0xffb2aa9a)
|
self.note=Text(self,18,0,'提醒:Boss 刷新后在聊天框提示;以服务器刷新为准。',0xffb2aa9a)
|
||||||
self.Reflow();self.Hide()
|
self.Reflow();self.Hide()
|
||||||
|
|
||||||
def Buttons(self):return [self.closeButton,self.refreshButton,self.prevButton,self.nextButton]+self.remindButtons
|
def Buttons(self):return [self.closeButton,self.refreshButton,self.prevButton,self.nextButton]+self.remindButtons+self.travelButtons
|
||||||
|
|
||||||
def Open(self):
|
def Open(self):
|
||||||
self.Reflow();self.SetCenterPosition();self.Show();self.SetTop();self.Query()
|
self.Reflow();self.SetCenterPosition();self.Show();self.SetTop();self.Query()
|
||||||
@@ -147,6 +162,12 @@ class BossWindow(ui.Window):
|
|||||||
rows=self.tracker.rows or [];index=self.page*PAGE+i
|
rows=self.tracker.rows or [];index=self.page*PAGE+i
|
||||||
if index<len(rows):self.tracker.Toggle(rows[index]);self.Render()
|
if index<len(rows):self.tracker.Toggle(rows[index]);self.Render()
|
||||||
|
|
||||||
|
def Teleport(self,i):
|
||||||
|
rows=self.tracker.rows or [];index=self.page*PAGE+i
|
||||||
|
if index>=len(rows) or rows[index]['vnum'] not in TRAVEL_LEVELS:return
|
||||||
|
self.travelButtons[i].SetEnabled(False)
|
||||||
|
Travel(rows[index]['vnum'],lambda data,error:self.Render())
|
||||||
|
|
||||||
def Render(self):
|
def Render(self):
|
||||||
t=self.tracker;rows=t.rows or [];self.page=min(self.page,self.Pages()-1)
|
t=self.tracker;rows=t.rows or [];self.page=min(self.page,self.Pages()-1)
|
||||||
if t.error:self.status.SetText(L('服务端未启用 Boss 追踪' if t.error=='unsupported' else '未同步,请稍后刷新'))
|
if t.error:self.status.SetText(L('服务端未启用 Boss 追踪' if t.error=='unsupported' else '未同步,请稍后刷新'))
|
||||||
@@ -156,6 +177,7 @@ class BossWindow(ui.Window):
|
|||||||
self.prevButton.SetEnabled(self.page>0);self.nextButton.SetEnabled(self.page+1<self.Pages())
|
self.prevButton.SetEnabled(self.page>0);self.nextButton.SetEnabled(self.page+1<self.Pages())
|
||||||
for i in range(PAGE):
|
for i in range(PAGE):
|
||||||
index=self.page*PAGE+i;line=self.lines[i];detail=self.details[i];button=self.remindButtons[i]
|
index=self.page*PAGE+i;line=self.lines[i];detail=self.details[i];button=self.remindButtons[i]
|
||||||
|
travel=self.travelButtons[i];travel.Hide()
|
||||||
if index>=len(rows):line.Hide();detail.Hide();button.Hide();continue
|
if index>=len(rows):line.Hide();detail.Hide();button.Hide();continue
|
||||||
r=rows[index];left=t.Left(r)
|
r=rows[index];left=t.Left(r)
|
||||||
channel='共享' if r['channel']==SHARED_CHANNEL else 'CH%d'%r['channel']
|
channel='共享' if r['channel']==SHARED_CHANNEL else 'CH%d'%r['channel']
|
||||||
@@ -169,6 +191,9 @@ class BossWindow(ui.Window):
|
|||||||
button.SetText(L('提醒:'+('开' if Key(r) in t.reminders else '关')))
|
button.SetText(L('提醒:'+('开' if Key(r) in t.reminders else '关')))
|
||||||
button.SetEnabled(bool(r['alive']==0 and r['next']) or Key(r) in t.reminders)
|
button.SetEnabled(bool(r['alive']==0 and r['next']) or Key(r) in t.reminders)
|
||||||
line.Show();detail.Show();button.Show()
|
line.Show();detail.Show();button.Show()
|
||||||
|
service=mt_feature_service.g_service
|
||||||
|
if r['vnum'] in TRAVEL_LEVELS and service and service.mask is not None and service.mask&512:
|
||||||
|
travel.Show();travel.SetEnabled(not service.pending and not service.queue)
|
||||||
|
|
||||||
def Reflow(self):
|
def Reflow(self):
|
||||||
dims=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight())
|
dims=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight())
|
||||||
@@ -179,6 +204,7 @@ class BossWindow(ui.Window):
|
|||||||
for i in range(PAGE):
|
for i in range(PAGE):
|
||||||
y=88+i*spacing
|
y=88+i*spacing
|
||||||
self.lines[i].SetPosition(18,y);self.details[i].SetPosition(30,y+18);self.remindButtons[i].SetPosition(width-110,y)
|
self.lines[i].SetPosition(18,y);self.details[i].SetPosition(30,y+18);self.remindButtons[i].SetPosition(width-110,y)
|
||||||
|
self.travelButtons[i].SetPosition(width-190,y)
|
||||||
self.note.SetPosition(18,height-22);self.SetCenterPosition()
|
self.note.SetPosition(18,height-22);self.SetCenterPosition()
|
||||||
|
|
||||||
def OnUpdate(self):
|
def OnUpdate(self):
|
||||||
|
|||||||
@@ -21,18 +21,22 @@ LEVEL_HINTS=('显示所有角色的特效','隐藏其他玩家和NPC的特效,
|
|||||||
|
|
||||||
|
|
||||||
class Options(object):
|
class Options(object):
|
||||||
def __init__(self):self.effect=0;self.tree=True;self.cloud=True
|
def __init__(self):self.effect=0;self.tree=True;self.cloud=True;self.shopRange=0;self.npcNames=True
|
||||||
|
|
||||||
def Load(self,text):
|
def Load(self,text):
|
||||||
words=text.split()
|
words=text.split()
|
||||||
if len(words)!=3 or not all(w.isdigit() and len(w)==1 for w in words):return
|
if len(words) not in (3,5) or not all(w.isdigit() and len(w)==1 for w in words):return
|
||||||
effect,tree,cloud=[int(w) for w in words]
|
effect,tree,cloud=[int(w) for w in words[:3]]
|
||||||
if effect<len(LEVELS) and tree in (0,1) and cloud in (0,1):self.effect=effect;self.tree=tree==1;self.cloud=cloud==1
|
shop,names=[int(w) for w in words[3:]] if len(words)==5 else (0,1)
|
||||||
|
if effect<len(LEVELS) and tree in (0,1) and cloud in (0,1) and shop<5 and names in (0,1):
|
||||||
|
self.effect=effect;self.tree=tree==1;self.cloud=cloud==1;self.shopRange=shop;self.npcNames=names==1
|
||||||
|
|
||||||
def Dump(self):return '%d %d %d'%(self.effect,int(self.tree),int(self.cloud))
|
def Dump(self):return '%d %d %d %d %d'%(self.effect,int(self.tree),int(self.cloud),self.shopRange,int(self.npcNames))
|
||||||
|
|
||||||
def Apply(self):
|
def Apply(self):
|
||||||
systemSetting.SetEffectLevel(self.effect)
|
systemSetting.SetEffectLevel(self.effect)
|
||||||
|
systemSetting.SetShopNamesRange(self.shopRange)
|
||||||
|
systemSetting.SetShopNameStatus(self.npcNames)
|
||||||
background.SetVisiblePart(background.PART_TREE,int(self.tree))
|
background.SetVisiblePart(background.PART_TREE,int(self.tree))
|
||||||
background.SetVisiblePart(background.PART_CLOUD,int(self.cloud))
|
background.SetVisiblePart(background.PART_CLOUD,int(self.cloud))
|
||||||
|
|
||||||
@@ -55,7 +59,7 @@ def Mark(text,on):return L(B(u'【%s】'%text if on else text))
|
|||||||
class DisplayWindow(ui.Window):
|
class DisplayWindow(ui.Window):
|
||||||
def __init__(self,options):
|
def __init__(self,options):
|
||||||
ui.Window.__init__(self);self.SetWindowName('DisplayWindow');self.AddFlag('float');self.AddFlag('movable')
|
ui.Window.__init__(self);self.SetWindowName('DisplayWindow');self.AddFlag('float');self.AddFlag('movable')
|
||||||
self.options=options;self.SetSize(332,404)
|
self.options=options;self.SetSize(332,456)
|
||||||
self.heading=Text(self,18,18,'显示设置',0xffffd68b)
|
self.heading=Text(self,18,18,'显示设置',0xffffd68b)
|
||||||
self.closeButton=Action(self,256,6,60,'关闭',self.Hide)
|
self.closeButton=Action(self,256,6,60,'关闭',self.Hide)
|
||||||
self.effectText=Text(self,18,54,'特效',0xffc3b7a1)
|
self.effectText=Text(self,18,54,'特效',0xffc3b7a1)
|
||||||
@@ -65,10 +69,12 @@ class DisplayWindow(ui.Window):
|
|||||||
self.cloudButton=Action(self,170,260,144,'',self.ToggleCloud)
|
self.cloudButton=Action(self,170,260,144,'',self.ToggleCloud)
|
||||||
self.nameButton=Action(self,18,312,144,'',self.ToggleName)
|
self.nameButton=Action(self,18,312,144,'',self.ToggleName)
|
||||||
self.damageButton=Action(self,170,312,144,'',self.ToggleDamage)
|
self.damageButton=Action(self,170,312,144,'',self.ToggleDamage)
|
||||||
self.status=Text(self,18,376,'',0xffc3b7a1)
|
self.rangeButton=Action(self,18,364,144,'',self.NextRange)
|
||||||
|
self.npcButton=Action(self,170,364,144,'',self.ToggleNPC)
|
||||||
|
self.status=Text(self,18,428,'',0xffc3b7a1)
|
||||||
self.Refresh();self.Hide()
|
self.Refresh();self.Hide()
|
||||||
|
|
||||||
def Buttons(self):return [self.closeButton,self.treeButton,self.cloudButton,self.nameButton,self.damageButton]+self.levelButtons
|
def Buttons(self):return [self.closeButton,self.treeButton,self.cloudButton,self.nameButton,self.damageButton,self.rangeButton,self.npcButton]+self.levelButtons
|
||||||
|
|
||||||
def Open(self):self.Refresh();self.SetCenterPosition();self.Show();self.SetTop()
|
def Open(self):self.Refresh();self.SetCenterPosition();self.Show();self.SetTop()
|
||||||
|
|
||||||
@@ -80,6 +86,10 @@ class DisplayWindow(ui.Window):
|
|||||||
|
|
||||||
def ToggleCloud(self):self.options.cloud=not self.options.cloud;self.Changed()
|
def ToggleCloud(self):self.options.cloud=not self.options.cloud;self.Changed()
|
||||||
|
|
||||||
|
def NextRange(self):self.options.shopRange=(self.options.shopRange+1)%5;self.Changed()
|
||||||
|
|
||||||
|
def ToggleNPC(self):self.options.npcNames=not self.options.npcNames;self.Changed()
|
||||||
|
|
||||||
# Names and damage numbers are the original game options (saved by the client config on exit).
|
# Names and damage numbers are the original game options (saved by the client config on exit).
|
||||||
def ToggleName(self):systemSetting.SetAlwaysShowNameFlag(not systemSetting.IsAlwaysShowName());self.Refresh()
|
def ToggleName(self):systemSetting.SetAlwaysShowNameFlag(not systemSetting.IsAlwaysShowName());self.Refresh()
|
||||||
|
|
||||||
@@ -91,6 +101,8 @@ class DisplayWindow(ui.Window):
|
|||||||
self.treeButton.SetText(Mark(u'显示树木',o.tree));self.cloudButton.SetText(Mark(u'显示云',o.cloud))
|
self.treeButton.SetText(Mark(u'显示树木',o.tree));self.cloudButton.SetText(Mark(u'显示云',o.cloud))
|
||||||
self.nameButton.SetText(Mark(u'总是显示名字',systemSetting.IsAlwaysShowName()))
|
self.nameButton.SetText(Mark(u'总是显示名字',systemSetting.IsAlwaysShowName()))
|
||||||
self.damageButton.SetText(Mark(u'显示伤害数字',systemSetting.IsShowDamage()))
|
self.damageButton.SetText(Mark(u'显示伤害数字',systemSetting.IsShowDamage()))
|
||||||
|
self.rangeButton.SetText(L(B(u'摊位范围 %d 米'%((100,60,40,20,10)[o.shopRange]))))
|
||||||
|
self.npcButton.SetText(Mark(u'显示NPC名称',o.npcNames))
|
||||||
self.status.SetText(L(LEVEL_HINTS[o.effect]))
|
self.status.SetText(L(LEVEL_HINTS[o.effect]))
|
||||||
|
|
||||||
def OnRender(self):
|
def OnRender(self):
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class DungeonWindow(ui.Window):
|
|||||||
self.rows=[Text(self,24,142+32*i,'',0xffefdfc3) for i in range(6)]
|
self.rows=[Text(self,24,142+32*i,'',0xffefdfc3) for i in range(6)]
|
||||||
self.remindButton=Action(self,18,0,146,'到期提醒:关',self.Remind)
|
self.remindButton=Action(self,18,0,146,'到期提醒:关',self.Remind)
|
||||||
self.entryButton=Action(self,180,0,146,'查看入口',self.Entry)
|
self.entryButton=Action(self,180,0,146,'查看入口',self.Entry)
|
||||||
|
self.travelButton=Action(self,342,0,146,'传送到入口',self.Travel)
|
||||||
self.note=Text(self,18,0,'当前仅接入已核实的龙穴及相关周任务。',0xffb2aa9a)
|
self.note=Text(self,18,0,'当前仅接入已核实的龙穴及相关周任务。',0xffb2aa9a)
|
||||||
self.Reflow();self.Hide()
|
self.Reflow();self.Hide()
|
||||||
|
|
||||||
@@ -92,6 +93,15 @@ class DungeonWindow(ui.Window):
|
|||||||
self.remindButton.SetText('到期提醒:'+('开' if self.reminder is not None and self.reminder[0]==self.tab else '关'))
|
self.remindButton.SetText('到期提醒:'+('开' if self.reminder is not None and self.reminder[0]==self.tab else '关'))
|
||||||
self.remindButton.SetEnabled(v is not None and not self.stale and (self.tab==0 or 'weekly_end' in v))
|
self.remindButton.SetEnabled(v is not None and not self.stale and (self.tab==0 or 'weekly_end' in v))
|
||||||
self.entryButton.SetEnabled(v is not None and not self.stale and (self.tab==0 or 'weekly_npc' in v))
|
self.entryButton.SetEnabled(v is not None and not self.stale and (self.tab==0 or 'weekly_npc' in v))
|
||||||
|
service=mt_feature_service.g_service
|
||||||
|
if self.tab==0 and service and service.mask is not None and service.mask&512:self.travelButton.Show()
|
||||||
|
else:self.travelButton.Hide()
|
||||||
|
self.travelButton.SetEnabled(v is not None and not self.stale)
|
||||||
|
|
||||||
|
def Travel(self):
|
||||||
|
if self.values is None or self.stale or self.tab!=0:return
|
||||||
|
import mt_boss
|
||||||
|
mt_boss.Travel(2493)
|
||||||
|
|
||||||
def Entry(self):
|
def Entry(self):
|
||||||
if self.values is None or self.stale:return
|
if self.values is None or self.stale:return
|
||||||
@@ -124,6 +134,7 @@ class DungeonWindow(ui.Window):
|
|||||||
if dims==self.dimensions:return
|
if dims==self.dimensions:return
|
||||||
self.dimensions=dims;width=min(660,max(500,dims[0]-16));height=min(450,max(300,dims[1]-16));self.SetSize(width,height)
|
self.dimensions=dims;width=min(660,max(500,dims[0]-16));height=min(450,max(300,dims[1]-16));self.SetSize(width,height)
|
||||||
self.closeButton.SetPosition(width-76,6)
|
self.closeButton.SetPosition(width-76,6)
|
||||||
|
self.travelButton.SetPosition(342,height-74)
|
||||||
spacing=min(32,max(16,(height-250)//5))
|
spacing=min(32,max(16,(height-250)//5))
|
||||||
for i,row in enumerate(self.rows):row.SetPosition(24,132+i*spacing)
|
for i,row in enumerate(self.rows):row.SetPosition(24,132+i*spacing)
|
||||||
self.remindButton.SetPosition(18,height-74);self.entryButton.SetPosition(180,height-74);self.note.SetPosition(18,height-18)
|
self.remindButton.SetPosition(18,height-74);self.entryButton.SetPosition(180,height-74);self.note.SetPosition(18,height-18)
|
||||||
@@ -148,7 +159,7 @@ class DungeonWindow(ui.Window):
|
|||||||
|
|
||||||
def Destroy(self):
|
def Destroy(self):
|
||||||
self.generation+=1;self.reminder=None;self.values=None;self.Hide()
|
self.generation+=1;self.reminder=None;self.values=None;self.Hide()
|
||||||
for b in (self.closeButton,self.lairButton,self.weeklyButton,self.refreshButton,self.remindButton,self.entryButton):b.event=None
|
for b in (self.closeButton,self.lairButton,self.weeklyButton,self.refreshButton,self.remindButton,self.entryButton,self.travelButton):b.event=None
|
||||||
|
|
||||||
|
|
||||||
class ReminderTick(ui.Window):
|
class ReminderTick(ui.Window):
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import net
|
|||||||
import ui
|
import ui
|
||||||
from mt_wiki_data import Number
|
from mt_wiki_data import Number
|
||||||
|
|
||||||
BITS={'mob':1,'chest':2,'stats':4,'sources':8,'dungeons':16,'calendar':32,'bosses':256}
|
BITS={'mob':1,'chest':2,'stats':4,'sources':8,'dungeons':16,'calendar':32,'bosses':256,'travel':512}
|
||||||
WHOLE=('stats','dungeons','bosses') # one reply with every row; no subject or page
|
WHOLE=('stats','dungeons','bosses') # one reply with every row; no subject or page
|
||||||
g_service=None
|
g_service=None
|
||||||
g_tick=None
|
g_tick=None
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// PORT: touch equivalents of AVA2's inventory preview / chest shortcuts.
|
||||||
|
// Native touch selection invokes ShowInventory; no synthetic modifier keys.
|
||||||
|
static const char c_szItemActionsScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||||
|
import game
|
||||||
|
import grp
|
||||||
|
import player
|
||||||
|
import ui
|
||||||
|
import uiInventory
|
||||||
|
import weakref
|
||||||
|
import wndMgr
|
||||||
|
from mt_biolog import Action
|
||||||
|
|
||||||
|
g_window=None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemActions(ui.Window):
|
||||||
|
def __init__(self):
|
||||||
|
ui.Window.__init__(self);self.AddFlag('float');self.SetWindowName('TouchItemActions')
|
||||||
|
self.snapshot=None;self.buttons=[];self.Hide()
|
||||||
|
|
||||||
|
def Clear(self):
|
||||||
|
self.Hide();self.snapshot=None
|
||||||
|
for button in self.buttons:button.Hide();button.event=None
|
||||||
|
self.buttons=[]
|
||||||
|
|
||||||
|
def Valid(self):
|
||||||
|
if not self.snapshot:return False
|
||||||
|
try:
|
||||||
|
owner,page,cell,vnum,count=self.snapshot
|
||||||
|
return (owner.IsShow() and owner.inventoryPageIndex==page and
|
||||||
|
player.GetItemIndex(cell)==vnum and player.GetItemCount(cell)==count)
|
||||||
|
except ReferenceError:return False
|
||||||
|
|
||||||
|
def Select(self,owner,cell):
|
||||||
|
self.Clear()
|
||||||
|
vnum=player.GetItemIndex(cell)
|
||||||
|
if not vnum:return
|
||||||
|
self.snapshot=(weakref.proxy(owner),owner.inventoryPageIndex,cell,vnum,player.GetItemCount(cell))
|
||||||
|
import mt_preview
|
||||||
|
import mt_chest
|
||||||
|
import mt_wiki
|
||||||
|
choices=[('百科',lambda:mt_wiki.OpenItem(vnum))]
|
||||||
|
if mt_preview.ItemSpec(vnum):choices.append(('模型预览',lambda:mt_preview.Preview('item',vnum)))
|
||||||
|
if mt_chest.g_window and mt_chest.Batchable(vnum):
|
||||||
|
choices.append(('批量开箱',lambda:mt_chest.g_window.Select(cell,vnum)))
|
||||||
|
choices.append(('关闭',self.Clear))
|
||||||
|
for i,(label,action) in enumerate(choices):
|
||||||
|
self.buttons.append(Action(self,8+i*108,6,100,label,lambda action=action:self.Run(action)))
|
||||||
|
self.SetSize(16+len(choices)*108,60)
|
||||||
|
x,y=owner.GetGlobalPosition()
|
||||||
|
self.SetPosition(max(0,min(x,wndMgr.GetScreenWidth()-self.GetWidth())),
|
||||||
|
max(0,min(y+owner.GetHeight(),wndMgr.GetScreenHeight()-self.GetHeight())))
|
||||||
|
self.Show();self.SetTop()
|
||||||
|
|
||||||
|
def Run(self,action):
|
||||||
|
valid=self.Valid();self.Clear()
|
||||||
|
if valid:action()
|
||||||
|
|
||||||
|
def OnUpdate(self):
|
||||||
|
if self.IsShow() and not self.Valid():self.Clear()
|
||||||
|
|
||||||
|
def OnRender(self):
|
||||||
|
x,y=self.GetGlobalPosition();grp.SetColor(0xf51b1916);grp.RenderBar(x,y,self.GetWidth(),self.GetHeight())
|
||||||
|
|
||||||
|
def OnPressEscapeKey(self):self.Clear();return True
|
||||||
|
|
||||||
|
|
||||||
|
def ShowInventory(owner,cell):
|
||||||
|
if g_window:g_window.Select(owner,cell)
|
||||||
|
|
||||||
|
|
||||||
|
_open=game.GameWindow.Open
|
||||||
|
_close=game.GameWindow.Close
|
||||||
|
|
||||||
|
def Open(self):
|
||||||
|
global g_window
|
||||||
|
_open(self);g_window=ItemActions()
|
||||||
|
|
||||||
|
def Close(self):
|
||||||
|
global g_window
|
||||||
|
if g_window:g_window.Clear();g_window=None
|
||||||
|
_close(self)
|
||||||
|
|
||||||
|
game.GameWindow.Open=Open
|
||||||
|
game.GameWindow.Close=Close
|
||||||
|
|
||||||
|
_use=uiInventory.InventoryWindow.UseItemSlot
|
||||||
|
|
||||||
|
def UseItemSlot(self,slot):
|
||||||
|
cell=self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slot)
|
||||||
|
vnum=player.GetItemIndex(cell)
|
||||||
|
if 25150<=vnum<=25161:
|
||||||
|
import mt_battlepass
|
||||||
|
if mt_battlepass.g_window:mt_battlepass.g_window.OfferTicket(cell,vnum)
|
||||||
|
return
|
||||||
|
if vnum in (50348,50349):
|
||||||
|
import mt_switchbot
|
||||||
|
if mt_switchbot.g_window:mt_switchbot.g_window.Open()
|
||||||
|
return
|
||||||
|
return _use(self,slot)
|
||||||
|
|
||||||
|
uiInventory.InventoryWindow.UseItemSlot=UseItemSlot
|
||||||
|
)PY";
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// PORT: networking is prepared by the native transport, independently of the login UI.
|
||||||
|
#include <Python.h>
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
#include "transport.h"
|
||||||
|
#include "MeshPortConfig.h"
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iterator>
|
||||||
|
namespace {
|
||||||
|
PyObject* event(PyObject*,PyObject*) { return PyInt_FromLong(mt::meshport::take_event()); }
|
||||||
|
PyObject* local(PyObject*,PyObject* args) {
|
||||||
|
const char* ip;if(!PyArg_ParseTuple(args,"s",&ip))return nullptr;
|
||||||
|
return PyBool_FromLong(mt::meshport::same_subnet(ip));
|
||||||
|
}
|
||||||
|
PyMethodDef methods[]={
|
||||||
|
{"event",event,METH_NOARGS,nullptr},{"same_subnet",local,METH_VARARGS,nullptr},
|
||||||
|
{nullptr,nullptr,0,nullptr}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
void initMeshPort() {
|
||||||
|
std::string config=kMeshPortConfig;
|
||||||
|
if(const char* path=std::getenv("MT_MESHPORT_CONFIG")) {
|
||||||
|
std::ifstream input(path,std::ios::binary);
|
||||||
|
config.assign(std::istreambuf_iterator<char>(input),{});
|
||||||
|
}
|
||||||
|
if(config.size()>65536)config.clear();
|
||||||
|
mt::meshport::configure(config);
|
||||||
|
Py_InitModule("_mt_meshport",methods);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// PORT: native transport owns routing; Python displays status and handles pending-login cancellation.
|
||||||
|
static const char* c_szMeshPortScript = R"PY(# -*- coding: utf-8 -*-
|
||||||
|
import app
|
||||||
|
import ui
|
||||||
|
import wndMgr
|
||||||
|
import player
|
||||||
|
import introLogin
|
||||||
|
import net
|
||||||
|
import _mt_meshport as native
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkNotice(ui.Window):
|
||||||
|
def __init__(self):
|
||||||
|
ui.Window.__init__(self)
|
||||||
|
self.label = ui.TextLine()
|
||||||
|
self.label.SetHorizontalAlignCenter()
|
||||||
|
self.hideAt = 0
|
||||||
|
self.Show()
|
||||||
|
|
||||||
|
def OnUpdate(self):
|
||||||
|
event = native.event()
|
||||||
|
if event:
|
||||||
|
messages = {1: '正在组网,等待动态分配 IP...',
|
||||||
|
2: '组网成功', 3: '组网连接失败,请检查网络后重试'}
|
||||||
|
self.label.SetText(player.AutoHuntLocalize(messages[event]))
|
||||||
|
self.label.SetPackedFontColor(0xff66ee88 if event == 2 else 0xffffdd77)
|
||||||
|
self.label.SetPosition(wndMgr.GetScreenWidth() / 2, 32)
|
||||||
|
self.label.Show()
|
||||||
|
self.label.SetTop()
|
||||||
|
self.hideAt = app.GetTime() + (65 if event == 1 else 6)
|
||||||
|
if self.hideAt and app.GetTime() >= self.hideAt:
|
||||||
|
self.label.Hide()
|
||||||
|
self.hideAt = 0
|
||||||
|
|
||||||
|
|
||||||
|
notice = NetworkNotice()
|
||||||
|
|
||||||
|
# Original login popup uses the focus callback for its Cancel button. Stop only
|
||||||
|
# the account/game transports still preparing; leave established sessions and
|
||||||
|
# background channel queries alone. No credentials or Connect methods are wrapped.
|
||||||
|
def _wrap_cancel(method):
|
||||||
|
def Cancel(self, *args):
|
||||||
|
if net.CancelPendingLogin():
|
||||||
|
notice.label.Hide()
|
||||||
|
notice.hideAt = 0
|
||||||
|
return method(self, *args)
|
||||||
|
return Cancel
|
||||||
|
|
||||||
|
introLogin.LoginWindow.SetPasswordEditLineFocus = _wrap_cancel(introLogin.LoginWindow.SetPasswordEditLineFocus)
|
||||||
|
introLogin.LoginWindow.OnPressExitKey = _wrap_cancel(introLogin.LoginWindow.OnPressExitKey)
|
||||||
|
introLogin.LoginWindow.Close = _wrap_cancel(introLogin.LoginWindow.Close)
|
||||||
|
)PY";
|
||||||
@@ -56,6 +56,9 @@ def _Select(self, slot):
|
|||||||
_last_time = app.GetTime()
|
_last_time = app.GetTime()
|
||||||
if _last[3]:
|
if _last[3]:
|
||||||
self.ShowToolTip(_last[2])
|
self.ShowToolTip(_last[2])
|
||||||
|
import sys
|
||||||
|
actions = sys.modules.get('mt_item_actions')
|
||||||
|
if actions:actions.ShowInventory(self, _last[2])
|
||||||
return
|
return
|
||||||
snapshot = _Snapshot(self, slot) if dragging and not mouse.isAttached() else None
|
snapshot = _Snapshot(self, slot) if dragging and not mouse.isAttached() else None
|
||||||
result = _OriginalSelect(self, slot)
|
result = _OriginalSelect(self, slot)
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// PORT: model preview, from AVA2's render-target window (uirendertarget.py). A wearable item is shown on a
|
||||||
|
// character of every race that may wear it, a monster with its motions; drag rotates, buttons zoom. Entry points:
|
||||||
|
// X + click on an inventory or shop item, and a "模型预览" line in the wiki detail. The native side is the
|
||||||
|
// renderTarget module (UserInterface/PythonRenderTargetModule.cpp). Design: docs/FEATURE-MODEL-PREVIEW.md.
|
||||||
|
static const char c_szModelPreviewScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||||
|
import app
|
||||||
|
import chat
|
||||||
|
import game
|
||||||
|
import grp
|
||||||
|
import item
|
||||||
|
import player
|
||||||
|
import renderTarget
|
||||||
|
import ui
|
||||||
|
import wndMgr
|
||||||
|
from mt_biolog import Action, Text, L
|
||||||
|
|
||||||
|
INDEX=1
|
||||||
|
VIEW_W=300
|
||||||
|
VIEW_H=330
|
||||||
|
MARGIN=15
|
||||||
|
TOP=58
|
||||||
|
ROW=54
|
||||||
|
ZOOM_MIN=-8
|
||||||
|
ZOOM_MAX=12
|
||||||
|
ITEM_ZOOM=2
|
||||||
|
PITCH_LIMIT=60.
|
||||||
|
# Paired by job: warrior, assassin, sura, shaman; male first. race%4 is the job, these races are male.
|
||||||
|
RACES=(0,4,5,1,2,6,7,3)
|
||||||
|
MALE=(0,5,2,7)
|
||||||
|
JOBS=('武士','刺客','修罗','法师')
|
||||||
|
MOTIONS=((1,'待机'),(13,'攻击'),(5,'受击'),(11,'死亡'),(33,'技能1'),(34,'技能2'),(35,'技能3'),(36,'技能4'))
|
||||||
|
|
||||||
|
g_window=None
|
||||||
|
|
||||||
|
|
||||||
|
def RaceName(race):
|
||||||
|
return '%s(%s)'%(JOBS[race%4],'男' if race in MALE else '女')
|
||||||
|
|
||||||
|
|
||||||
|
def ItemSpec(vnum):
|
||||||
|
# (slot, races that may wear it) for a wearable item with a model, None otherwise.
|
||||||
|
try:
|
||||||
|
item.SelectItem(vnum)
|
||||||
|
kind=item.GetItemType();sub=item.GetItemSubType()
|
||||||
|
except (RuntimeError,TypeError):return None
|
||||||
|
if kind==item.ITEM_TYPE_WEAPON and sub!=item.WEAPON_ARROW:slot='weapon'
|
||||||
|
elif kind==item.ITEM_TYPE_ARMOR and sub==item.ARMOR_BODY:slot='armor'
|
||||||
|
elif kind==item.ITEM_TYPE_COSTUME and sub==item.COSTUME_TYPE_BODY:slot='armor'
|
||||||
|
elif kind==item.ITEM_TYPE_COSTUME and sub==item.COSTUME_TYPE_HAIR:slot='hair'
|
||||||
|
else:return None
|
||||||
|
jobs=(item.ITEM_ANTIFLAG_WARRIOR,item.ITEM_ANTIFLAG_ASSASSIN,item.ITEM_ANTIFLAG_SURA,item.ITEM_ANTIFLAG_SHAMAN)
|
||||||
|
races=[r for r in RACES if not item.IsAntiFlag(jobs[r%4]) and
|
||||||
|
not item.IsAntiFlag(item.ITEM_ANTIFLAG_MALE if r in MALE else item.ITEM_ANTIFLAG_FEMALE)]
|
||||||
|
if not races:return None
|
||||||
|
return slot,races
|
||||||
|
|
||||||
|
|
||||||
|
def Previewable(kind,vnum):
|
||||||
|
return kind=='mob' or ItemSpec(vnum) is not None
|
||||||
|
|
||||||
|
|
||||||
|
class View(ui.Window):
|
||||||
|
# The render target is drawn over this window; a left drag turns the camera around the model.
|
||||||
|
def __init__(self,owner):
|
||||||
|
ui.Window.__init__(self);self.owner=owner;self.drag=None
|
||||||
|
|
||||||
|
def OnMouseLeftButtonDown(self):
|
||||||
|
x,y=wndMgr.GetMousePosition();o=self.owner;self.drag=(x,y,o.yaw,o.pitch);return True
|
||||||
|
|
||||||
|
def OnMouseLeftButtonUp(self):
|
||||||
|
self.drag=None;return True
|
||||||
|
|
||||||
|
def OnUpdate(self):
|
||||||
|
if self.drag:
|
||||||
|
x,y=wndMgr.GetMousePosition();x0,y0,yaw,pitch=self.drag
|
||||||
|
self.owner.Turn(yaw+(x-x0)*.5,pitch+(y-y0)*.3)
|
||||||
|
|
||||||
|
def OnRender(self):
|
||||||
|
x,y=self.GetGlobalPosition();w=self.GetWidth();h=self.GetHeight()
|
||||||
|
grp.SetColor(0xff000000);grp.RenderBar(x,y,w,h)
|
||||||
|
renderTarget.RenderTexture(INDEX,x,y,x+w,y+h)
|
||||||
|
grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,w,h)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewWindow(ui.Window):
|
||||||
|
def __init__(self):
|
||||||
|
ui.Window.__init__(self);self.SetWindowName('ModelPreviewWindow');self.AddFlag('float');self.AddFlag('movable')
|
||||||
|
width=VIEW_W+2*MARGIN;self.SetSize(width,TOP+VIEW_H+ROW+15)
|
||||||
|
self.heading=Text(self,18,18,'模型预览',0xffffd68b)
|
||||||
|
self.closeButton=Action(self,width-76,4,64,'关闭',self.Close)
|
||||||
|
self.view=View(self);self.view.SetParent(self);self.view.SetPosition(MARGIN,TOP);self.view.SetSize(VIEW_W,VIEW_H);self.view.Show()
|
||||||
|
self.note=Text(self,MARGIN+8,TOP+8,'',0xffc3b7a1)
|
||||||
|
y=TOP+VIEW_H+6
|
||||||
|
self.prevRace=Action(self,MARGIN,y,56,'<',lambda:self.StepRace(-1))
|
||||||
|
self.raceName=Text(self,width/2,y+16,'',0xffe8dec8);self.raceName.SetHorizontalAlignCenter()
|
||||||
|
self.nextRace=Action(self,width-MARGIN-56,y,56,'>',lambda:self.StepRace(1))
|
||||||
|
step=(VIEW_W+6)/4
|
||||||
|
self.motions=[Action(self,MARGIN+(i%4)*step,y+(i//4)*ROW,step-6,name,(lambda m=motion:self.Motion(m)))
|
||||||
|
for i,(motion,name) in enumerate(MOTIONS)]
|
||||||
|
self.controls=[Action(self,MARGIN+i*step,0,step-6,name,event) for i,(name,event) in enumerate((
|
||||||
|
('放大',lambda:self.Zoom(-1)),('缩小',lambda:self.Zoom(1)),('自动旋转',self.ToggleSpin),('复位',self.Apply)))]
|
||||||
|
self.kind=None;self.vnum=0;self.slot=None;self.races=[];self.race=0
|
||||||
|
self.yaw=0.;self.pitch=0.;self.zoom=0;self.spin=False;self.ready=False
|
||||||
|
self.Hide()
|
||||||
|
|
||||||
|
def Open(self,kind,vnum):
|
||||||
|
if kind=='mob':slot,races=None,[]
|
||||||
|
else:
|
||||||
|
spec=ItemSpec(vnum)
|
||||||
|
if not spec:return False
|
||||||
|
slot,races=spec
|
||||||
|
renderTarget.Remove(INDEX)
|
||||||
|
if not renderTarget.Create(INDEX,VIEW_W,VIEW_H):return False
|
||||||
|
renderTarget.SetVisibility(INDEX,True)
|
||||||
|
self.kind=kind;self.vnum=vnum;self.slot=slot;self.races=races
|
||||||
|
if races:
|
||||||
|
try:own=player.GetRace()
|
||||||
|
except RuntimeError:own=-1
|
||||||
|
self.race=own if own in races else races[0]
|
||||||
|
self.Layout();self.Apply()
|
||||||
|
if not self.IsShow():self.SetCenterPosition()
|
||||||
|
ui.Window.Show(self);self.SetTop()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def Layout(self):
|
||||||
|
item_mode=self.kind!='mob';rows=1 if item_mode else (len(MOTIONS)+3)//4
|
||||||
|
y=TOP+VIEW_H+6
|
||||||
|
for w in (self.prevRace,self.nextRace,self.raceName):
|
||||||
|
if item_mode:w.Show()
|
||||||
|
else:w.Hide()
|
||||||
|
for b in self.motions:
|
||||||
|
if item_mode:b.Hide()
|
||||||
|
else:b.Show()
|
||||||
|
bottom=y+rows*ROW
|
||||||
|
step=(VIEW_W+6)/4
|
||||||
|
for i,b in enumerate(self.controls):b.SetPosition(MARGIN+i*step,bottom)
|
||||||
|
self.SetSize(VIEW_W+2*MARGIN,bottom+ROW+10)
|
||||||
|
many=len(self.races)>1
|
||||||
|
self.prevRace.SetEnabled(many);self.nextRace.SetEnabled(many)
|
||||||
|
|
||||||
|
def Apply(self):
|
||||||
|
# Rebuilds the model: also the reset button, since SelectModel puts the camera back.
|
||||||
|
renderTarget.ResetModel(INDEX)
|
||||||
|
self.yaw=0.;self.pitch=0.;self.zoom=0
|
||||||
|
if self.kind=='mob':
|
||||||
|
self.ready=bool(renderTarget.SelectModel(INDEX,self.vnum))
|
||||||
|
note='' if self.ready else '该怪物没有模型'
|
||||||
|
else:
|
||||||
|
self.ready=bool(renderTarget.SelectModel(INDEX,self.race))
|
||||||
|
if self.ready:
|
||||||
|
if self.slot=='armor':renderTarget.SetArmor(INDEX,self.vnum)
|
||||||
|
elif self.slot=='weapon':renderTarget.SetWeapon(INDEX,self.vnum)
|
||||||
|
else:renderTarget.SetHair(INDEX,self.vnum)
|
||||||
|
# The default camera cuts the feet of a player race in this view; start two zoom steps out.
|
||||||
|
if self.ready:
|
||||||
|
for _ in range(ITEM_ZOOM):renderTarget.Zoom(INDEX,1)
|
||||||
|
note='' if self.ready else '模型加载失败'
|
||||||
|
self.raceName.SetText(L(RaceName(self.race)))
|
||||||
|
renderTarget.SetRotation(INDEX,self.spin)
|
||||||
|
renderTarget.RotateEyeAroundTarget(INDEX,0.,0.)
|
||||||
|
self.note.SetText(L(note))
|
||||||
|
|
||||||
|
def StepRace(self,delta):
|
||||||
|
if len(self.races)<2:return
|
||||||
|
self.race=self.races[(self.races.index(self.race)+delta)%len(self.races)];self.Apply()
|
||||||
|
|
||||||
|
def Motion(self,motion):
|
||||||
|
if self.ready and not renderTarget.SetMotionIndex(INDEX,motion):self.note.SetText(L('该怪物没有此动作'))
|
||||||
|
else:self.note.SetText('')
|
||||||
|
|
||||||
|
def Zoom(self,direction):
|
||||||
|
if not self.ready or not ZOOM_MIN<=self.zoom+direction<=ZOOM_MAX:return
|
||||||
|
self.zoom+=direction;renderTarget.Zoom(INDEX,direction)
|
||||||
|
|
||||||
|
def Turn(self,yaw,pitch):
|
||||||
|
self.yaw=yaw%360.;self.pitch=max(-PITCH_LIMIT,min(PITCH_LIMIT,pitch))
|
||||||
|
renderTarget.RotateEyeAroundTarget(INDEX,self.pitch,self.yaw)
|
||||||
|
|
||||||
|
def ToggleSpin(self):
|
||||||
|
self.spin=not self.spin;renderTarget.SetRotation(INDEX,self.spin)
|
||||||
|
self.controls[2].SetText('停止旋转' if self.spin else '自动旋转')
|
||||||
|
|
||||||
|
def Close(self):
|
||||||
|
self.Hide()
|
||||||
|
|
||||||
|
def Hide(self):
|
||||||
|
# The target holds a GPU texture and a model; drop both whenever the window is not shown.
|
||||||
|
# ui.Window.__init__ hides the window before the view exists.
|
||||||
|
if getattr(self,'view',None):self.view.drag=None
|
||||||
|
self.ready=False
|
||||||
|
renderTarget.Remove(INDEX)
|
||||||
|
ui.Window.Hide(self)
|
||||||
|
|
||||||
|
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 OnPressEscapeKey(self):self.Hide();return True
|
||||||
|
|
||||||
|
def Destroy(self):
|
||||||
|
self.Hide();self.closeButton.event=None;self.prevRace.event=self.nextRace.event=None
|
||||||
|
for b in self.motions+self.controls:b.event=None
|
||||||
|
self.view.owner=None
|
||||||
|
|
||||||
|
|
||||||
|
def Preview(kind,vnum):
|
||||||
|
if not g_window:return False
|
||||||
|
if not g_window.Open(kind,vnum):
|
||||||
|
chat.AppendChat(chat.CHAT_TYPE_INFO,L('该物品没有可预览的模型'));return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def KeyHeld():
|
||||||
|
return app.IsPressed(app.DIK_X)
|
||||||
|
|
||||||
|
|
||||||
|
_open=game.GameWindow.Open
|
||||||
|
_close=game.GameWindow.Close
|
||||||
|
|
||||||
|
def Open(self):
|
||||||
|
global g_window
|
||||||
|
_open(self);g_window=PreviewWindow()
|
||||||
|
|
||||||
|
def Close(self):
|
||||||
|
global g_window
|
||||||
|
if g_window:g_window.Destroy();g_window=None
|
||||||
|
renderTarget.Destroy()
|
||||||
|
_close(self)
|
||||||
|
|
||||||
|
game.GameWindow.Open=Open
|
||||||
|
game.GameWindow.Close=Close
|
||||||
|
|
||||||
|
try:
|
||||||
|
import uiInventory
|
||||||
|
_inventorySelect=uiInventory.InventoryWindow.SelectItemSlot
|
||||||
|
def InventorySelect(self,slot):
|
||||||
|
if KeyHeld():
|
||||||
|
vnum=player.GetItemIndex(self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slot))
|
||||||
|
if vnum and ItemSpec(vnum):Preview('item',vnum);return
|
||||||
|
return _inventorySelect(self,slot)
|
||||||
|
uiInventory.InventoryWindow.SelectItemSlot=InventorySelect
|
||||||
|
except (ImportError,AttributeError):pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
import shop
|
||||||
|
import uiShop
|
||||||
|
_shopSelect=uiShop.ShopDialog.SelectItemSlot
|
||||||
|
def ShopSelect(self,slot):
|
||||||
|
if KeyHeld():
|
||||||
|
real=getattr(self,'_ShopDialog__GetRealIndex',None)
|
||||||
|
vnum=shop.GetItemID(real(slot) if real else slot)
|
||||||
|
if vnum and ItemSpec(vnum):Preview('item',vnum);return
|
||||||
|
return _shopSelect(self,slot)
|
||||||
|
uiShop.ShopDialog.SelectItemSlot=ShopSelect
|
||||||
|
except (ImportError,AttributeError):pass
|
||||||
|
)PY";
|
||||||
@@ -44,8 +44,12 @@
|
|||||||
#include "UserInterface/PythonSystem.h"
|
#include "UserInterface/PythonSystem.h"
|
||||||
#include "UserInterface/AbstractApplication.h"
|
#include "UserInterface/AbstractApplication.h"
|
||||||
#include "UserInterface/PythonApplication.h"
|
#include "UserInterface/PythonApplication.h"
|
||||||
|
#include "UserInterface/CRenderTargetManager.h"
|
||||||
#include "EterBase/Timer.h"
|
#include "EterBase/Timer.h"
|
||||||
#include "PythonBoot.h"
|
#include "PythonBoot.h"
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
#include "transport.h"
|
||||||
|
#endif
|
||||||
#include "../UserInterface/UserInterface.h"
|
#include "../UserInterface/UserInterface.h"
|
||||||
#include "PythonHost.h"
|
#include "PythonHost.h"
|
||||||
#include "../EterLib/UIRenderCommands.h"
|
#include "../EterLib/UIRenderCommands.h"
|
||||||
@@ -157,6 +161,7 @@ struct GameSingletons
|
|||||||
CGuildMarkDownloader kGuildMarkDownloader;
|
CGuildMarkDownloader kGuildMarkDownloader;
|
||||||
CGuildMarkUploader kGuildMarkUploader;
|
CGuildMarkUploader kGuildMarkUploader;
|
||||||
CPythonSystem pySystem; // last CPythonApplication member (m_pySystem)
|
CPythonSystem pySystem; // last CPythonApplication member (m_pySystem)
|
||||||
|
CRenderTargetManager renderTargets; // PORT: AVA2 model preview; destroyed first, its models use the rest
|
||||||
};
|
};
|
||||||
std::unique_ptr<GameSingletons> g_game_singletons;
|
std::unique_ptr<GameSingletons> g_game_singletons;
|
||||||
bool g_host_hardware_cursor_enabled = false;
|
bool g_host_hardware_cursor_enabled = false;
|
||||||
@@ -248,6 +253,10 @@ void* fiber_main(void*)
|
|||||||
}
|
}
|
||||||
|
|
||||||
void init_2v0_gameplay_stubs();
|
void init_2v0_gameplay_stubs();
|
||||||
|
void StopAccountRegistration(); // PORT: drain registration before networking teardown.
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
void initMeshPort();
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace PythonBoot
|
namespace PythonBoot
|
||||||
{
|
{
|
||||||
@@ -897,6 +906,9 @@ bool run_main_script_body(const char* lpCmdLine, std::string* error)
|
|||||||
initChat();
|
initChat();
|
||||||
initTextTail();
|
initTextTail();
|
||||||
initnet();
|
initnet();
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
initMeshPort();
|
||||||
|
#endif
|
||||||
initMiniMap();
|
initMiniMap();
|
||||||
initEvent();
|
initEvent();
|
||||||
initeffect();
|
initeffect();
|
||||||
@@ -911,6 +923,7 @@ bool run_main_script_body(const char* lpCmdLine, std::string* error)
|
|||||||
initsafebox();
|
initsafebox();
|
||||||
initguild();
|
initguild();
|
||||||
initServerStateChecker();
|
initServerStateChecker();
|
||||||
|
initRenderTarget(); // PORT: AVA2 model preview
|
||||||
init_2v0_gameplay_stubs();
|
init_2v0_gameplay_stubs();
|
||||||
|
|
||||||
ClearLogBoxMessage();
|
ClearLogBoxMessage();
|
||||||
@@ -1005,6 +1018,13 @@ namespace PythonBoot
|
|||||||
#include "DisplayScript.inc"
|
#include "DisplayScript.inc"
|
||||||
#include "ChannelScript.inc"
|
#include "ChannelScript.inc"
|
||||||
#include "BossScript.inc"
|
#include "BossScript.inc"
|
||||||
|
#include "VipScript.inc"
|
||||||
|
#include "ModelPreviewScript.inc"
|
||||||
|
#include "BattlePassScript.inc"
|
||||||
|
#include "ItemActionsScript.inc"
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
#include "MeshPortScript.inc"
|
||||||
|
#endif
|
||||||
|
|
||||||
static void install_script_module(const char* c_szModule, const char* c_szScript)
|
static void install_script_module(const char* c_szModule, const char* c_szScript)
|
||||||
{
|
{
|
||||||
@@ -1060,6 +1080,9 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
|
|||||||
{
|
{
|
||||||
install_script_module("mt_autohunt", c_szAutoHuntScript);
|
install_script_module("mt_autohunt", c_szAutoHuntScript);
|
||||||
install_script_module("mt_register", c_szRegisterScript);
|
install_script_module("mt_register", c_szRegisterScript);
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
install_script_module("mt_meshport", c_szMeshPortScript);
|
||||||
|
#endif
|
||||||
install_script_module("mt_framerate", c_szFrameRateOptionScript);
|
install_script_module("mt_framerate", c_szFrameRateOptionScript);
|
||||||
install_script_module("mt_uiscale", c_szUIScaleOptionScript);
|
install_script_module("mt_uiscale", c_szUIScaleOptionScript);
|
||||||
install_script_module("mt_inventory", c_szInventoryPagesScript);
|
install_script_module("mt_inventory", c_szInventoryPagesScript);
|
||||||
@@ -1084,6 +1107,10 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
|
|||||||
install_script_module("mt_display", c_szDisplayScript);
|
install_script_module("mt_display", c_szDisplayScript);
|
||||||
install_script_module("mt_channel", c_szChannelScript);
|
install_script_module("mt_channel", c_szChannelScript);
|
||||||
install_script_module("mt_boss", c_szBossScript);
|
install_script_module("mt_boss", c_szBossScript);
|
||||||
|
install_script_module("mt_vip", c_szVipScript);
|
||||||
|
install_script_module("mt_preview", c_szModelPreviewScript);
|
||||||
|
install_script_module("mt_battlepass", c_szBattlePassScript);
|
||||||
|
install_script_module("mt_item_actions", c_szItemActionsScript);
|
||||||
if (g_touch_input)
|
if (g_touch_input)
|
||||||
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
|
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
|
||||||
}
|
}
|
||||||
@@ -1188,6 +1215,10 @@ void Stop()
|
|||||||
" getattr(_mm.mouseController, 'cursorDict', {}).clear()\n");
|
" getattr(_mm.mouseController, 'cursorDict', {}).clear()\n");
|
||||||
PyGILState_Release(gil);
|
PyGILState_Release(gil);
|
||||||
}
|
}
|
||||||
|
StopAccountRegistration();
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
mt::meshport::shutdown(); // PORT: join SDK worker and stop mesh before Python/platform teardown.
|
||||||
|
#endif
|
||||||
g_launcher->Clear();
|
g_launcher->Clear();
|
||||||
g_game_singletons.reset();
|
g_game_singletons.reset();
|
||||||
g_game_application.reset();
|
g_game_application.reset();
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ class RegisterDialog(ui.BoardWithTitleBar):
|
|||||||
ui.__mem_func__(self.Close))
|
ui.__mem_func__(self.Close))
|
||||||
|
|
||||||
def Destroy(self):
|
def Destroy(self):
|
||||||
|
if self.pending:
|
||||||
|
net.CancelRegisterAccount()
|
||||||
self.Hide()
|
self.Hide()
|
||||||
for row in (self.loginRow, self.passwordRow, self.confirmRow, self.socialRow):
|
for row in (self.loginRow, self.passwordRow, self.confirmRow, self.socialRow):
|
||||||
row.Destroy()
|
row.Destroy()
|
||||||
@@ -178,6 +180,11 @@ class RegisterDialog(ui.BoardWithTitleBar):
|
|||||||
self.loginRow.edit.SetFocus()
|
self.loginRow.edit.SetFocus()
|
||||||
|
|
||||||
def Close(self):
|
def Close(self):
|
||||||
|
if self.pending:
|
||||||
|
net.CancelRegisterAccount()
|
||||||
|
self.pending = False
|
||||||
|
self.pendingPassword = ""
|
||||||
|
self.registerButton.Enable()
|
||||||
for row in (self.loginRow, self.passwordRow, self.confirmRow, self.socialRow):
|
for row in (self.loginRow, self.passwordRow, self.confirmRow, self.socialRow):
|
||||||
row.edit.KillFocus()
|
row.edit.KillFocus()
|
||||||
self.Hide()
|
self.Hide()
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ REASONS={'running':'洗练中','matched':'已获得目标属性','limit':'已达
|
|||||||
ERRORS={'rate':'操作过快,请稍后','invalid':'参数无效','item':'物品不存在','equipped':'请先卸下装备或结束交易','no_attr':'该物品不能洗练',
|
ERRORS={'rate':'操作过快,请稍后','invalid':'参数无效','item':'物品不存在','equipped':'请先卸下装备或结束交易','no_attr':'该物品不能洗练',
|
||||||
'scroll':'没有可用的洗练道具','target':'目标属性超出可洗范围','running':'已有洗练在进行','busy':'当前状态不能洗练',
|
'scroll':'没有可用的洗练道具','target':'目标属性超出可洗范围','running':'已有洗练在进行','busy':'当前状态不能洗练',
|
||||||
'unsupported':'服务器尚未启用自动洗练','slot':'该条属性不能锁定','max':'最多锁定 2 条,且至少留 1 条可洗',
|
'unsupported':'服务器尚未启用自动洗练','slot':'该条属性不能锁定','max':'最多锁定 2 条,且至少留 1 条可洗',
|
||||||
'db':'锁定保存失败,请稍后再试'}
|
'db':'锁定保存失败,请稍后再试','five_attr':'装备需要 5 条普通属性才能锁定','material':'锁定需要道具 50348,解锁需要道具 50349'}
|
||||||
|
|
||||||
|
|
||||||
def Number(text,low=0,high=4294967295):
|
def Number(text,low=0,high=4294967295):
|
||||||
@@ -188,7 +188,7 @@ class SwitchbotWindow(ui.Window):
|
|||||||
vnum=player.GetItemIndex(player.INVENTORY,cell)
|
vnum=player.GetItemIndex(player.INVENTORY,cell)
|
||||||
if not vnum:continue
|
if not vnum:continue
|
||||||
item.SelectItem(vnum);kind=item.GetItemType()
|
item.SelectItem(vnum);kind=item.GetItemType()
|
||||||
if kind==item.ITEM_TYPE_USE and item.GetItemSubType()==USE_CHANGE_ATTRIBUTE:
|
if kind==item.ITEM_TYPE_USE and item.GetItemSubType()==USE_CHANGE_ATTRIBUTE and vnum not in (50348,50349):
|
||||||
scrolls[vnum]=scrolls.get(vnum,0)+player.GetItemCount(player.INVENTORY,cell)
|
scrolls[vnum]=scrolls.get(vnum,0)+player.GetItemCount(player.INVENTORY,cell)
|
||||||
elif kind in (item.ITEM_TYPE_WEAPON,item.ITEM_TYPE_ARMOR) and any(player.GetItemAttribute(player.INVENTORY,cell,i)[0] for i in range(5)):
|
elif kind in (item.ITEM_TYPE_WEAPON,item.ITEM_TYPE_ARMOR) and any(player.GetItemAttribute(player.INVENTORY,cell,i)[0] for i in range(5)):
|
||||||
items.append((cell,vnum))
|
items.append((cell,vnum))
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// PORT: VIP panel. The server's VIP cards (38001 7 days, 38002 30 days, sold by 百店老板娘) extend the seven
|
||||||
|
// original premium services of the account; this window shows what is left of each, asked with /mt_vip, and
|
||||||
|
// reports card use in the chat. The premium icons (affects 500..506) are the original ones.
|
||||||
|
// Protocol: metin2-server docs/FEATURE-VIP.md.
|
||||||
|
static const char c_szVipScript[] = R"PY(# -*- coding: utf-8 -*-
|
||||||
|
import app
|
||||||
|
import chat
|
||||||
|
import grp
|
||||||
|
import net
|
||||||
|
import ui
|
||||||
|
import game
|
||||||
|
import weakref
|
||||||
|
from mt_biolog import Action, Text, L
|
||||||
|
|
||||||
|
g_window=None
|
||||||
|
g_entry=None
|
||||||
|
SEND_GAP=1.
|
||||||
|
TIMEOUT=3.
|
||||||
|
MAX_DAYS=400
|
||||||
|
CARDS={38001:7,38002:30}
|
||||||
|
# PREMIUM_EXP .. PREMIUM_GOLD, the order of MTVIP1 times.
|
||||||
|
SERVICES=('经验加成','物品掉落翻倍','仓库扩展到 3 页','金币直接进背包','钓鱼成功率提升','结婚亲密度加快','金币掉落翻倍')
|
||||||
|
ERRORS={'rate':'操作过快,请稍后再试','invalid':'参数无效','full':'VIP 剩余时间不能超过 %d 天'%MAX_DAYS}
|
||||||
|
|
||||||
|
|
||||||
|
def Number(text,low=0,high=2**31-1):
|
||||||
|
if not text.isdigit() or len(text)>10:raise ValueError(text)
|
||||||
|
value=int(text)
|
||||||
|
if not low<=value<=high:raise ValueError(text)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def Remaining(seconds):
|
||||||
|
seconds=int(seconds)
|
||||||
|
if seconds<=0:return '未开通'
|
||||||
|
days,rest=divmod(seconds,86400);hours,rest=divmod(rest,3600);minutes=rest//60
|
||||||
|
if days:return '剩余 %d 天 %d 小时'%(days,hours)
|
||||||
|
if hours:return '剩余 %d 小时 %d 分'%(hours,minutes)
|
||||||
|
return '剩余 %d 分'%max(1,minutes)
|
||||||
|
|
||||||
|
|
||||||
|
class Status(object):
|
||||||
|
# Protocol state only. send(text) posts a chat command, now() is seconds.
|
||||||
|
def __init__(self,send,now,listener=None):
|
||||||
|
self.send=send;self.now=now;self.listener=listener
|
||||||
|
self.waiting=None;self.nextSend=0;self.unsupported=False
|
||||||
|
self.times=None;self.at=0;self.error=None;self.added=None
|
||||||
|
|
||||||
|
def Query(self):
|
||||||
|
self.Tick()
|
||||||
|
now=self.now()
|
||||||
|
if self.waiting is not None or now<self.nextSend:return False
|
||||||
|
self.send('/mt_vip 1 status');self.waiting=now+TIMEOUT;self.nextSend=now+SEND_GAP
|
||||||
|
return True
|
||||||
|
|
||||||
|
def Tick(self):
|
||||||
|
if self.waiting is not None and self.now()>=self.waiting:
|
||||||
|
self.waiting=None;self.unsupported=True;self.Notify('unsupported')
|
||||||
|
|
||||||
|
def Left(self,index):
|
||||||
|
if self.times is None:return 0
|
||||||
|
return max(0,self.times[index]-(self.now()-self.at))
|
||||||
|
|
||||||
|
def Notify(self,kind):
|
||||||
|
if self.listener:self.listener(kind)
|
||||||
|
|
||||||
|
def Receive(self,line):
|
||||||
|
if not line.startswith('MTVIP1 '):return False
|
||||||
|
words=line.split()
|
||||||
|
try:
|
||||||
|
if words[1]=='times' and len(words)==2+len(SERVICES):
|
||||||
|
self.times=[Number(w) for w in words[2:]];self.at=self.now();self.error=None;kind='times'
|
||||||
|
elif words[1]=='added' and len(words)==4:
|
||||||
|
self.added=(Number(words[2]),Number(words[3],1,MAX_DAYS));kind='added'
|
||||||
|
elif words[1]=='error' and len(words)==3 and words[2] in ERRORS:self.error=words[2];kind='error'
|
||||||
|
else:return True
|
||||||
|
except ValueError:return True
|
||||||
|
if kind!='added':self.waiting=None
|
||||||
|
self.unsupported=False;self.Notify(kind)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class VipWindow(ui.Window):
|
||||||
|
def __init__(self,status):
|
||||||
|
ui.Window.__init__(self);self.SetWindowName('VipWindow');self.AddFlag('float');self.AddFlag('movable')
|
||||||
|
self.status=status;self.SetSize(332,118+len(SERVICES)*24)
|
||||||
|
self.heading=Text(self,18,18,'VIP 会员',0xffffd68b)
|
||||||
|
self.closeButton=Action(self,256,6,60,'关闭',self.Hide)
|
||||||
|
self.names=[Text(self,18,64+i*24,name,0xffe8dec8) for i,name in enumerate(SERVICES)]
|
||||||
|
self.values=[Text(self,190,64+i*24,'',0xffc3b7a1) for i in range(len(SERVICES))]
|
||||||
|
self.note=Text(self,18,self.GetHeight()-44,'',0xffc3b7a1)
|
||||||
|
self.hint=Text(self,18,self.GetHeight()-22,'VIP 卡在各村百店老板娘处购买,使用后全部特权延长',0xff8f8676)
|
||||||
|
self.second=-1
|
||||||
|
self.Hide()
|
||||||
|
|
||||||
|
def Open(self):
|
||||||
|
self.status.Query();self.Refresh();self.SetCenterPosition();self.Show();self.SetTop()
|
||||||
|
|
||||||
|
def Toggle(self):
|
||||||
|
if self.IsShow():self.Hide()
|
||||||
|
else:self.Open()
|
||||||
|
|
||||||
|
def Refresh(self):
|
||||||
|
s=self.status
|
||||||
|
for i,value in enumerate(self.values):
|
||||||
|
left=s.Left(i)
|
||||||
|
value.SetText(L(Remaining(left) if s.times is not None else '……'))
|
||||||
|
value.SetPackedFontColor(0xff9fe08b if left>0 else 0xff8f8676)
|
||||||
|
if s.unsupported:note='!服务器尚未启用 VIP 卡'
|
||||||
|
elif s.error:note='!'+ERRORS[s.error]
|
||||||
|
elif s.times is None:note='正在查询……'
|
||||||
|
elif any(s.Left(i)>0 for i in range(len(SERVICES))):note='VIP 生效中'
|
||||||
|
else:note='尚未开通 VIP'
|
||||||
|
self.note.SetText(L(note))
|
||||||
|
|
||||||
|
def OnUpdate(self):
|
||||||
|
self.status.Tick()
|
||||||
|
second=int(app.GetTime())
|
||||||
|
if second!=self.second:self.second=second;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 OnPressEscapeKey(self):self.Hide();return True
|
||||||
|
|
||||||
|
def Destroy(self):
|
||||||
|
self.Hide();self.closeButton.event=None
|
||||||
|
|
||||||
|
|
||||||
|
class Entry(Action):
|
||||||
|
def __init__(self,window):
|
||||||
|
Action.__init__(self,None,0,0,64,'VIP',Toggle)
|
||||||
|
self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('VipEntry');self.OnUpdate()
|
||||||
|
|
||||||
|
def OnUpdate(self):
|
||||||
|
x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+408)
|
||||||
|
|
||||||
|
|
||||||
|
def OnStatus(kind):
|
||||||
|
s=g_status
|
||||||
|
if kind=='added':
|
||||||
|
vnum,days=s.added
|
||||||
|
chat.AppendChat(chat.CHAT_TYPE_INFO,L('VIP 会员已延长 %d 天,全部特权立即生效'%days))
|
||||||
|
elif kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[s.error]))
|
||||||
|
if g_window:g_window.Refresh()
|
||||||
|
|
||||||
|
|
||||||
|
g_status=Status(net.SendChatPacket,app.GetTime,OnStatus)
|
||||||
|
|
||||||
|
|
||||||
|
def Toggle():
|
||||||
|
if g_window:g_window.Toggle()
|
||||||
|
|
||||||
|
|
||||||
|
_open=game.GameWindow.Open
|
||||||
|
_close=game.GameWindow.Close
|
||||||
|
_command=game.GameWindow.BINARY_ServerCommand_Run
|
||||||
|
|
||||||
|
def Open(self):
|
||||||
|
global g_window,g_entry
|
||||||
|
_open(self);g_status.waiting=None;g_status.times=None;g_window=VipWindow(g_status);g_entry=Entry(self)
|
||||||
|
|
||||||
|
def Close(self):
|
||||||
|
global g_window,g_entry
|
||||||
|
if g_entry:g_entry.Hide();g_entry.event=None;g_entry=None
|
||||||
|
if g_window:g_window.Destroy();g_window=None
|
||||||
|
_close(self)
|
||||||
|
|
||||||
|
def Command(self,line):
|
||||||
|
if g_status.Receive(line):return 1
|
||||||
|
return _command(self,line)
|
||||||
|
|
||||||
|
game.GameWindow.Open=Open
|
||||||
|
game.GameWindow.Close=Close
|
||||||
|
game.GameWindow.BINARY_ServerCommand_Run=Command
|
||||||
|
)PY";
|
||||||
@@ -184,6 +184,9 @@ class WikiWindow(ui.Window):
|
|||||||
if row:self.Select('mobs',row)
|
if row:self.Select('mobs',row)
|
||||||
else:self.QueryRemote('mob',vnum[1],0)
|
else:self.QueryRemote('mob',vnum[1],0)
|
||||||
elif vnum[0]=='items':self.Open(vnum[1])
|
elif vnum[0]=='items':self.Open(vnum[1])
|
||||||
|
elif vnum[0]=='preview':
|
||||||
|
import mt_preview
|
||||||
|
mt_preview.Preview(vnum[1],vnum[2])
|
||||||
return
|
return
|
||||||
row=self.catalogue.FindItem(vnum)
|
row=self.catalogue.FindItem(vnum)
|
||||||
if row:self.Select('items',row)
|
if row:self.Select('items',row)
|
||||||
@@ -232,12 +235,15 @@ class WikiWindow(ui.Window):
|
|||||||
else:
|
else:
|
||||||
cost=self.catalogue.FindItem(offer['cost'])
|
cost=self.catalogue.FindItem(offer['cost'])
|
||||||
lines.append((u'研究兑换:'+(cost['name'] if cost else u'#%d'%offer['cost'])+u' ×%d'%offer['amount'],offer['cost'] if cost else 0))
|
lines.append((u'研究兑换:'+(cost['name'] if cost else u'#%d'%offer['cost'])+u' ×%d'%offer['amount'],offer['cost'] if cost else 0))
|
||||||
|
if row['kind'] in ('ITEM_WEAPON','ITEM_ARMOR','ITEM_COSTUME'):
|
||||||
|
import mt_preview
|
||||||
|
if mt_preview.Previewable('item',vnum):lines.append((u'模型预览',('preview','item',vnum)))
|
||||||
lines.append((u'查看获取来源',('query','sources',vnum)))
|
lines.append((u'查看获取来源',('query','sources',vnum)))
|
||||||
if row['kind'] in ('ITEM_GIFTBOX','ITEM_TREASURE_BOX'):lines.append((u'宝箱奖励预览',('query','chest',vnum)))
|
if row['kind'] in ('ITEM_GIFTBOX','ITEM_TREASURE_BOX'):lines.append((u'宝箱奖励预览',('query','chest',vnum)))
|
||||||
else:
|
else:
|
||||||
lines=[(u'类型:'+U('矿石' if row['kind']=='STONE' else '怪物')+u' / '+U(RANK_NAMES.get(row['rank'],'普通')),0),(u'等级:%d 生命:%d'%(row['level'],row['hp']),0),
|
lines=[(u'类型:'+U('矿石' if row['kind']=='STONE' else '怪物')+u' / '+U(RANK_NAMES.get(row['rank'],'普通')),0),(u'等级:%d 生命:%d'%(row['level'],row['hp']),0),
|
||||||
(u'基础攻击:%d—%d'%(row['attack_min'],row['attack_max']),0),
|
(u'基础攻击:%d—%d'%(row['attack_min'],row['attack_max']),0),
|
||||||
(u'基础防御:%d 经验:%d'%(row['defense'],row['exp']),0),(u'查看服务端掉落列表',('query','mob',vnum))]
|
(u'基础防御:%d 经验:%d'%(row['defense'],row['exp']),0),(u'模型预览',('preview','mob',vnum)),(u'查看服务端掉落列表',('query','mob',vnum))]
|
||||||
if self.remote:lines=self.RemoteLines()
|
if self.remote:lines=self.RemoteLines()
|
||||||
self.links=lines
|
self.links=lines
|
||||||
pages=max(1,(len(lines)+self.rowCount-1)//self.rowCount)
|
pages=max(1,(len(lines)+self.rowCount-1)//self.rowCount)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
#include "UserInterface/PythonSystem.h"
|
#include "UserInterface/PythonSystem.h"
|
||||||
#include "../ScriptLib/PythonBoot.h"
|
#include "../ScriptLib/PythonBoot.h"
|
||||||
#include "ServerClock.h"
|
#include "ServerClock.h"
|
||||||
|
#include "UserInterface/CRenderTargetManager.h"
|
||||||
#include "../EterBase/PerfCounters.h"
|
#include "../EterBase/PerfCounters.h"
|
||||||
|
|
||||||
#if defined(__linux__) || defined(__ANDROID__)
|
#if defined(__linux__) || defined(__ANDROID__)
|
||||||
@@ -213,6 +214,7 @@ void CPythonApplication::RenderGame()
|
|||||||
CCullingManager::Instance().Process();
|
CCullingManager::Instance().Process();
|
||||||
|
|
||||||
CPythonCharacterManager::Instance().Deform();
|
CPythonCharacterManager::Instance().Deform();
|
||||||
|
CRenderTargetManager::Instance().DeformModels(); // PORT: AVA2 model preview
|
||||||
CEffectManager::Instance().Update();
|
CEffectManager::Instance().Update();
|
||||||
|
|
||||||
if (!SkipDiagnosticRender("characters")) CPythonBackground::Instance().RenderCharacterShadowToTexture();
|
if (!SkipDiagnosticRender("characters")) CPythonBackground::Instance().RenderCharacterShadowToTexture();
|
||||||
@@ -239,6 +241,7 @@ void CPythonApplication::RenderGame()
|
|||||||
|
|
||||||
CPythonBackground::Instance().SetCharacterDirLight();
|
CPythonBackground::Instance().SetCharacterDirLight();
|
||||||
if (!SkipDiagnosticRender("characters")) CPythonCharacterManager::Instance().Render();
|
if (!SkipDiagnosticRender("characters")) CPythonCharacterManager::Instance().Render();
|
||||||
|
CRenderTargetManager::Instance().RenderModels(); // PORT: AVA2, under the character light
|
||||||
|
|
||||||
CPythonBackground::Instance().SetBackgroundDirLight();
|
CPythonBackground::Instance().SetBackgroundDirLight();
|
||||||
CPythonBackground::Instance().RenderWater();
|
CPythonBackground::Instance().RenderWater();
|
||||||
@@ -279,6 +282,8 @@ void CPythonApplication::UpdateGame()
|
|||||||
s.BuildViewFrustum();
|
s.BuildViewFrustum();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CRenderTargetManager::Instance().UpdateModels(); // PORT: AVA2 model preview
|
||||||
|
|
||||||
TPixelPosition kPPosMainActor;
|
TPixelPosition kPPosMainActor;
|
||||||
CPythonPlayer::Instance().NEW_GetMainActorPosition(&kPPosMainActor);
|
CPythonPlayer::Instance().NEW_GetMainActorPosition(&kPPosMainActor);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ if(NOT TARGET mtpython)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
add_library(port_logic STATIC ${MT_PORT_SOURCES})
|
add_library(port_logic STATIC ${MT_PORT_SOURCES})
|
||||||
|
if(TARGET mt_meshport)
|
||||||
|
target_link_libraries(port_logic PUBLIC mt_meshport)
|
||||||
|
endif()
|
||||||
target_include_directories(port_logic PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(port_logic PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
# 40250 system headers the ported sources keep including verbatim: D3D8/D3DX/Miles SDK types everywhere,
|
# 40250 system headers the ported sources keep including verbatim: D3D8/D3DX/Miles SDK types everywhere,
|
||||||
# Windows/CRT ones only where the platform has none.
|
# Windows/CRT ones only where the platform has none.
|
||||||
@@ -326,14 +329,24 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
|||||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_channel.py)
|
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_channel.py)
|
||||||
add_test(NAME port.boss
|
add_test(NAME port.boss
|
||||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_boss.py)
|
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_boss.py)
|
||||||
|
add_test(NAME port.battlepass
|
||||||
|
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_battlepass.py)
|
||||||
|
add_test(NAME port.model_preview
|
||||||
|
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_model_preview.py)
|
||||||
|
add_test(NAME port.item_actions
|
||||||
|
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_item_actions.py)
|
||||||
add_test(NAME port.chest
|
add_test(NAME port.chest
|
||||||
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_chest.py)
|
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tests/wiki/test_chest.py)
|
||||||
|
add_test(NAME port.ava2_items
|
||||||
|
COMMAND ${MT_WIKI_PYTHON3} ${PROJECT_SOURCE_DIR}/tools/server/ava2-items/tests/test_items.py)
|
||||||
endif()
|
endif()
|
||||||
# Offline locale data builder: retains 40250 proto layout and gameplay fields.
|
# 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)
|
add_executable(locale_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/locale_proto_patch.cpp)
|
||||||
target_link_libraries(locale_proto_patch PRIVATE port_platform)
|
target_link_libraries(locale_proto_patch PRIVATE port_platform)
|
||||||
add_executable(biolog_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/biolog_proto_patch.cpp)
|
add_executable(biolog_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/biolog_proto_patch.cpp)
|
||||||
target_link_libraries(biolog_proto_patch PRIVATE port_platform)
|
target_link_libraries(biolog_proto_patch PRIVATE port_platform)
|
||||||
|
add_executable(item_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/item_proto_patch.cpp)
|
||||||
|
target_link_libraries(item_proto_patch PRIVATE port_platform)
|
||||||
# Locale packs in the original format (scripts/build_zh_locale.py packs locale/zh with it).
|
# 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)
|
add_executable(eterpack_pack ${PROJECT_SOURCE_DIR}/tests/port/eterpack_pack.cpp)
|
||||||
target_link_libraries(eterpack_pack PRIVATE port_platform)
|
target_link_libraries(eterpack_pack PRIVATE port_platform)
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ class CGraphicBase
|
|||||||
void SetOrtho3D(float hres, float vres, float zmin, float zmax);
|
void SetOrtho3D(float hres, float vres, float zmin, float zmax);
|
||||||
void SetPerspective(float fov, float aspect, float nearz, float farz);
|
void SetPerspective(float fov, float aspect, float nearz, float farz);
|
||||||
float GetFOV();
|
float GetFOV();
|
||||||
|
float GetAspect() { return ms_fAspect; } // PORT: AVA2 model preview restores the perspective
|
||||||
void GetClipPlane(float * fNearY, float * fFarY)
|
void GetClipPlane(float * fNearY, float * fFarY)
|
||||||
{
|
{
|
||||||
*fNearY = ms_fNearY;
|
*fNearY = ms_fNearY;
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#include "StdAfx.h"
|
||||||
|
#include "../EterBase/Stl.h"
|
||||||
|
#include "GrpRenderTargetTexture.h"
|
||||||
|
#include "StateManager.h"
|
||||||
|
|
||||||
|
CGraphicRenderTargetTexture::CGraphicRenderTargetTexture(): m_d3dFormat{ D3DFMT_UNKNOWN }, m_depthStencilFormat{ D3DFMT_UNKNOWN }
|
||||||
|
{
|
||||||
|
Initialize();
|
||||||
|
memset(&m_renderRect, 0, sizeof(m_renderRect));
|
||||||
|
}
|
||||||
|
|
||||||
|
CGraphicRenderTargetTexture::~CGraphicRenderTargetTexture()
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CGraphicRenderTargetTexture::ReleaseTextures()
|
||||||
|
{
|
||||||
|
safe_release(m_lpd3dRenderTexture);
|
||||||
|
safe_release(m_lpd3dRenderTargetSurface);
|
||||||
|
safe_release(m_lpd3dDepthSurface);
|
||||||
|
safe_release(m_lpd3dOriginalRenderTarget);
|
||||||
|
safe_release(m_lpd3dOldDepthBufferSurface);
|
||||||
|
memset(&m_renderRect, 0, sizeof(m_renderRect));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CGraphicRenderTargetTexture::Create(const int width, const int height, const D3DFORMAT texFormat, const D3DFORMAT depthFormat)
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
m_height = height;
|
||||||
|
m_width = width;
|
||||||
|
if (!CreateRenderTexture(width, height, texFormat))
|
||||||
|
return false;
|
||||||
|
if (!CreateRenderDepthStencil(width, height, depthFormat))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CGraphicRenderTargetTexture::CreateTextures()
|
||||||
|
{
|
||||||
|
if (CreateRenderTexture(m_width, m_height, m_d3dFormat))
|
||||||
|
CreateRenderDepthStencil(m_width, m_height, m_depthStencilFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CGraphicRenderTargetTexture::CreateRenderTexture(const int width, const int height, const D3DFORMAT format)
|
||||||
|
{
|
||||||
|
m_d3dFormat = format;
|
||||||
|
// PORT: one level (AVA2 asked for a full chain, 0). Only level 0 is a render target on the recording
|
||||||
|
// device, and the preview is drawn 1:1.
|
||||||
|
if (FAILED(ms_lpd3dDevice->CreateTexture(width, height, 1, D3DUSAGE_RENDERTARGET, m_d3dFormat, D3DPOOL_DEFAULT, &m_lpd3dRenderTexture)))
|
||||||
|
return false;
|
||||||
|
if (FAILED(m_lpd3dRenderTexture->GetSurfaceLevel(0, &m_lpd3dRenderTargetSurface)))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CGraphicRenderTargetTexture::CreateRenderDepthStencil(const int width, const int height, const D3DFORMAT format)
|
||||||
|
{
|
||||||
|
m_depthStencilFormat = format;
|
||||||
|
|
||||||
|
return (ms_lpd3dDevice->CreateDepthStencilSurface(width, height, m_depthStencilFormat, D3DMULTISAMPLE_NONE, &m_lpd3dDepthSurface)) == D3D_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CGraphicRenderTargetTexture::SetRenderTarget()
|
||||||
|
{
|
||||||
|
ms_lpd3dDevice->GetRenderTarget(&m_lpd3dOriginalRenderTarget);
|
||||||
|
ms_lpd3dDevice->GetDepthStencilSurface(&m_lpd3dOldDepthBufferSurface);
|
||||||
|
ms_lpd3dDevice->SetRenderTarget(m_lpd3dRenderTargetSurface, m_lpd3dDepthSurface);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CGraphicRenderTargetTexture::ResetRenderTarget()
|
||||||
|
{
|
||||||
|
ms_lpd3dDevice->SetRenderTarget(m_lpd3dOriginalRenderTarget, m_lpd3dOldDepthBufferSurface);
|
||||||
|
|
||||||
|
safe_release(m_lpd3dOriginalRenderTarget);
|
||||||
|
safe_release(m_lpd3dOldDepthBufferSurface);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CGraphicRenderTargetTexture::Clear()
|
||||||
|
{
|
||||||
|
ms_lpd3dDevice->Clear(0, nullptr, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER, 0x00000000 /* PORT: D3DCOLOR_ARGB(0, 0, 0, 0) */, 1.0f, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CGraphicRenderTargetTexture::Render() const
|
||||||
|
{
|
||||||
|
D3DSURFACE_DESC desc;
|
||||||
|
m_lpd3dRenderTargetSurface->GetDesc(&desc);
|
||||||
|
|
||||||
|
const auto imgWidth = static_cast<float>(m_renderRect.right - m_renderRect.left);
|
||||||
|
const auto imgHeight = static_cast<float>(m_renderRect.bottom - m_renderRect.top);
|
||||||
|
|
||||||
|
const auto su = 0.0f;
|
||||||
|
const auto sv = 0.0f;
|
||||||
|
const auto eu = imgWidth / static_cast<float>(desc.Width);
|
||||||
|
const auto ev = imgHeight / static_cast<float>(desc.Height);
|
||||||
|
|
||||||
|
TPDTVertex pVertices[4];
|
||||||
|
|
||||||
|
pVertices[0].position = TPosition(m_renderRect.left - 0.5f, m_renderRect.top - 0.5f, 0.0f);
|
||||||
|
pVertices[0].texCoord = TTextureCoordinate(su, sv);
|
||||||
|
pVertices[0].diffuse = 0xffffffff;
|
||||||
|
|
||||||
|
pVertices[1].position = TPosition(m_renderRect.right - 0.5f, m_renderRect.top - 0.5f, 0.0f);
|
||||||
|
pVertices[1].texCoord = TTextureCoordinate(eu, sv);
|
||||||
|
pVertices[1].diffuse = 0xffffffff;
|
||||||
|
|
||||||
|
pVertices[2].position = TPosition(m_renderRect.left - 0.5f, m_renderRect.bottom - 0.5f, 0.0f);
|
||||||
|
pVertices[2].texCoord = TTextureCoordinate(su, ev);
|
||||||
|
pVertices[2].diffuse = 0xffffffff;
|
||||||
|
|
||||||
|
pVertices[3].position = TPosition(m_renderRect.right - 0.5f, m_renderRect.bottom - 0.5f, 0.0f);
|
||||||
|
pVertices[3].texCoord = TTextureCoordinate(eu, ev);
|
||||||
|
pVertices[3].diffuse = 0xffffffff;
|
||||||
|
|
||||||
|
if (SetPDTStream(pVertices, 4))
|
||||||
|
{
|
||||||
|
SetDefaultIndexBuffer(DEFAULT_IB_FILL_RECT);
|
||||||
|
STATEMANAGER.SetTexture(0, GetRenderTargetTexture());
|
||||||
|
STATEMANAGER.SetTexture(1, nullptr);
|
||||||
|
STATEMANAGER.SetVertexShader(D3DFVF_XYZ | D3DFVF_TEX1 | D3DFVF_DIFFUSE);
|
||||||
|
STATEMANAGER.DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 0, 4, 0, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CGraphicRenderTargetTexture::Reset()
|
||||||
|
{
|
||||||
|
Destroy();
|
||||||
|
ReleaseTextures();
|
||||||
|
m_d3dFormat = D3DFMT_UNKNOWN;
|
||||||
|
m_depthStencilFormat = D3DFMT_UNKNOWN;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#pragma once
|
||||||
|
// PORT: AVA2 CGraphicRenderTargetTexture (model preview). On the recording device a 32-bit render-target
|
||||||
|
// texture is a GPU colour target ("rt:<id>:<w>x<h>:c"); see platform/EterLib/RecordingDevice.cpp.
|
||||||
|
#include "GrpBase.h"
|
||||||
|
#include "GrpTexture.h"
|
||||||
|
|
||||||
|
class CGraphicRenderTargetTexture : public CGraphicTexture
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
CGraphicRenderTargetTexture();
|
||||||
|
virtual ~CGraphicRenderTargetTexture();
|
||||||
|
|
||||||
|
public:
|
||||||
|
bool Create(int width, int height, D3DFORMAT texFormat, D3DFORMAT depthFormat);
|
||||||
|
void CreateTextures();
|
||||||
|
bool CreateRenderTexture(int width, int height, D3DFORMAT format);
|
||||||
|
bool CreateRenderDepthStencil(int width, int height, D3DFORMAT format);
|
||||||
|
|
||||||
|
void SetRenderTarget();
|
||||||
|
void ResetRenderTarget();
|
||||||
|
|
||||||
|
void SetRenderingRect(RECT* rect){ m_renderRect = *rect; }
|
||||||
|
RECT* GetRenderingRect(){ return &m_renderRect; }
|
||||||
|
|
||||||
|
LPDIRECT3DTEXTURE8 GetRenderTargetTexture() const { return m_lpd3dRenderTexture; }
|
||||||
|
|
||||||
|
void ReleaseTextures();
|
||||||
|
static void Clear();
|
||||||
|
|
||||||
|
void Render() const;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void Reset();
|
||||||
|
LPDIRECT3DTEXTURE8 m_lpd3dRenderTexture{};
|
||||||
|
LPDIRECT3DSURFACE8 m_lpd3dRenderTargetSurface{};
|
||||||
|
LPDIRECT3DSURFACE8 m_lpd3dDepthSurface{};
|
||||||
|
|
||||||
|
LPDIRECT3DSURFACE8 m_lpd3dOriginalRenderTarget{};
|
||||||
|
LPDIRECT3DSURFACE8 m_lpd3dOldDepthBufferSurface{};
|
||||||
|
D3DFORMAT m_d3dFormat;
|
||||||
|
D3DFORMAT m_depthStencilFormat;
|
||||||
|
RECT m_renderRect{};
|
||||||
|
};
|
||||||
@@ -32,9 +32,15 @@ void CNetworkAddress::Clear()
|
|||||||
|
|
||||||
bool CNetworkAddress::IsIP(const char* c_szAddr)
|
bool CNetworkAddress::IsIP(const char* c_szAddr)
|
||||||
{
|
{
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
// PORT: digit-prefixed hostnames must resolve before the common subnet decision.
|
||||||
|
in_addr parsed{};
|
||||||
|
return c_szAddr && inet_pton(AF_INET,c_szAddr,&parsed) == 1;
|
||||||
|
#else
|
||||||
if (c_szAddr[0]<'0' || c_szAddr[0]>'9')
|
if (c_szAddr[0]<'0' || c_szAddr[0]>'9')
|
||||||
return false;
|
return false;
|
||||||
return true;
|
return true;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool CNetworkAddress::Set(const char* c_szAddr, int port)
|
bool CNetworkAddress::Set(const char* c_szAddr, int port)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#include "StdAfx.h"
|
#include "StdAfx.h"
|
||||||
#include "NetDatagram.h"
|
#include "NetDatagram.h"
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
#include "transport.h" // PORT: preserve datagram payload/source/peek semantics over application-only mesh.
|
||||||
|
#endif
|
||||||
|
|
||||||
CNetworkDatagram::CNetworkDatagram()
|
CNetworkDatagram::CNetworkDatagram()
|
||||||
{
|
{
|
||||||
@@ -16,6 +19,9 @@ void CNetworkDatagram::Destroy()
|
|||||||
if (INVALID_SOCKET==m_sock)
|
if (INVALID_SOCKET==m_sock)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
mt::meshport::forget_socket(m_sock);
|
||||||
|
#endif
|
||||||
closesocket(m_sock);
|
closesocket(m_sock);
|
||||||
|
|
||||||
__Initialize();
|
__Initialize();
|
||||||
@@ -68,6 +74,9 @@ void CNetworkDatagram::Update()
|
|||||||
|
|
||||||
bool CNetworkDatagram::CanRecv()
|
bool CNetworkDatagram::CanRecv()
|
||||||
{
|
{
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
if (mt::meshport::datagram_ready(m_sock)) return true;
|
||||||
|
#endif
|
||||||
if (FD_ISSET(m_sock, &m_fdsRecv))
|
if (FD_ISSET(m_sock, &m_fdsRecv))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@@ -77,19 +86,31 @@ bool CNetworkDatagram::CanRecv()
|
|||||||
|
|
||||||
int CNetworkDatagram::PeekRecvFrom(UINT uBufLen, void* pvBuf, SOCKADDR_IN* pkSockAddrIn)
|
int CNetworkDatagram::PeekRecvFrom(UINT uBufLen, void* pvBuf, SOCKADDR_IN* pkSockAddrIn)
|
||||||
{
|
{
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
return mt::meshport::recv_datagram(m_sock,pvBuf,uBufLen,MSG_PEEK,pkSockAddrIn);
|
||||||
|
#else
|
||||||
int nSockAddrInLen=sizeof(SOCKADDR_IN);
|
int nSockAddrInLen=sizeof(SOCKADDR_IN);
|
||||||
return recvfrom(m_sock, (char*)pvBuf, uBufLen, MSG_PEEK, (PSOCKADDR)pkSockAddrIn, &nSockAddrInLen);
|
return recvfrom(m_sock, (char*)pvBuf, uBufLen, MSG_PEEK, (PSOCKADDR)pkSockAddrIn, &nSockAddrInLen);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
int CNetworkDatagram::RecvFrom(UINT uBufLen, void* pvBuf, SOCKADDR_IN* pkSockAddrIn)
|
int CNetworkDatagram::RecvFrom(UINT uBufLen, void* pvBuf, SOCKADDR_IN* pkSockAddrIn)
|
||||||
{
|
{
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
return mt::meshport::recv_datagram(m_sock,pvBuf,uBufLen,0,pkSockAddrIn);
|
||||||
|
#else
|
||||||
int nSockAddrInLen=sizeof(SOCKADDR_IN);
|
int nSockAddrInLen=sizeof(SOCKADDR_IN);
|
||||||
return recvfrom(m_sock, (char*)pvBuf, uBufLen, 0, (PSOCKADDR)pkSockAddrIn, &nSockAddrInLen);
|
return recvfrom(m_sock, (char*)pvBuf, uBufLen, 0, (PSOCKADDR)pkSockAddrIn, &nSockAddrInLen);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
int CNetworkDatagram::SendTo(UINT uBufLen, const void* c_pvBuf, const SOCKADDR_IN& c_rkSockAddrIn)
|
int CNetworkDatagram::SendTo(UINT uBufLen, const void* c_pvBuf, const SOCKADDR_IN& c_rkSockAddrIn)
|
||||||
{
|
{
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
return mt::meshport::send_datagram(m_sock,c_pvBuf,uBufLen,0,c_rkSockAddrIn);
|
||||||
|
#else
|
||||||
return sendto(m_sock, (const char *)c_pvBuf, uBufLen, 0, (PSOCKADDR)&c_rkSockAddrIn, sizeof(SOCKADDR_IN));
|
return sendto(m_sock, (const char *)c_pvBuf, uBufLen, 0, (PSOCKADDR)&c_rkSockAddrIn, sizeof(SOCKADDR_IN));
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
#include "StdAfx.h"
|
#include "StdAfx.h"
|
||||||
#include "NetDatagramReceiver.h"
|
#include "NetDatagramReceiver.h"
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
#include "transport.h" // PORT: shared datagram receive and relay lifetime.
|
||||||
|
#endif
|
||||||
|
|
||||||
BOOL CNetDatagramReceiver::Process()
|
BOOL CNetDatagramReceiver::Process()
|
||||||
{
|
{
|
||||||
m_recvBufCurrentPos = 0;
|
m_recvBufCurrentPos = 0;
|
||||||
m_recvBufCurrentSize = 0;
|
m_recvBufCurrentSize = 0;
|
||||||
|
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
m_recvBufCurrentSize = mt::meshport::recv_datagram(m_Socket,m_recvBuf,m_recvBufSize,0,&m_SockAddr);
|
||||||
|
#else
|
||||||
int irecvAddrLength = sizeof(SOCKADDR_IN);
|
int irecvAddrLength = sizeof(SOCKADDR_IN);
|
||||||
m_recvBufCurrentSize = recvfrom(m_Socket, (char *)m_recvBuf, m_recvBufSize, 0, (PSOCKADDR)&m_SockAddr, &irecvAddrLength);
|
m_recvBufCurrentSize = recvfrom(m_Socket, (char *)m_recvBuf, m_recvBufSize, 0, (PSOCKADDR)&m_SockAddr, &irecvAddrLength);
|
||||||
|
#endif
|
||||||
|
|
||||||
if (m_recvBufCurrentSize <= 0)
|
if (m_recvBufCurrentSize <= 0)
|
||||||
{
|
{
|
||||||
@@ -42,7 +49,9 @@ BOOL CNetDatagramReceiver::isBind()
|
|||||||
|
|
||||||
BOOL CNetDatagramReceiver::Bind(DWORD /*dwAddress*/, WORD wPortIndex)
|
BOOL CNetDatagramReceiver::Bind(DWORD /*dwAddress*/, WORD wPortIndex)
|
||||||
{
|
{
|
||||||
|
Clear(); // PORT: rebinding releases any previous UDP/mesh association.
|
||||||
m_Socket = socket(AF_INET, SOCK_DGRAM, 0);
|
m_Socket = socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
if (m_Socket == INVALID_SOCKET) return FALSE;
|
||||||
|
|
||||||
DWORD arg = 1;
|
DWORD arg = 1;
|
||||||
ioctlsocket(m_Socket, FIONBIO, &arg); // Non-blocking mode
|
ioctlsocket(m_Socket, FIONBIO, &arg); // Non-blocking mode
|
||||||
@@ -56,6 +65,8 @@ BOOL CNetDatagramReceiver::Bind(DWORD /*dwAddress*/, WORD wPortIndex)
|
|||||||
if (bind(m_Socket, (PSOCKADDR)&m_SockAddr, sizeof(SOCKADDR_IN)) < 0)
|
if (bind(m_Socket, (PSOCKADDR)&m_SockAddr, sizeof(SOCKADDR_IN)) < 0)
|
||||||
{
|
{
|
||||||
Tracef("Failed binding socket\n");
|
Tracef("Failed binding socket\n");
|
||||||
|
closesocket(m_Socket);
|
||||||
|
m_Socket = INVALID_SOCKET;
|
||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +90,12 @@ void CNetDatagramReceiver::SetRecvBufferSize(int recvBufSize)
|
|||||||
|
|
||||||
void CNetDatagramReceiver::Clear()
|
void CNetDatagramReceiver::Clear()
|
||||||
{
|
{
|
||||||
|
if (m_isBind) {
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
mt::meshport::forget_socket(m_Socket);
|
||||||
|
#endif
|
||||||
|
closesocket(m_Socket); // PORT: close UDP and any mesh association before resetting state.
|
||||||
|
}
|
||||||
|
|
||||||
m_isBind = FALSE;
|
m_isBind = FALSE;
|
||||||
|
|
||||||
@@ -93,6 +110,7 @@ void CNetDatagramReceiver::Clear()
|
|||||||
|
|
||||||
CNetDatagramReceiver::CNetDatagramReceiver()
|
CNetDatagramReceiver::CNetDatagramReceiver()
|
||||||
{
|
{
|
||||||
|
m_isBind = FALSE;
|
||||||
m_recvBuf = NULL;
|
m_recvBuf = NULL;
|
||||||
m_recvBufSize = 0;
|
m_recvBufSize = 0;
|
||||||
|
|
||||||
@@ -100,6 +118,7 @@ CNetDatagramReceiver::CNetDatagramReceiver()
|
|||||||
}
|
}
|
||||||
CNetDatagramReceiver::~CNetDatagramReceiver()
|
CNetDatagramReceiver::~CNetDatagramReceiver()
|
||||||
{
|
{
|
||||||
|
Clear();
|
||||||
if (m_recvBuf)
|
if (m_recvBuf)
|
||||||
delete [] m_recvBuf;
|
delete [] m_recvBuf;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
#include "StdAfx.h"
|
#include "StdAfx.h"
|
||||||
#include "NetDatagramSender.h"
|
#include "NetDatagramSender.h"
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
#include "transport.h" // PORT: common LAN/mesh policy and relay lifetime.
|
||||||
|
#endif
|
||||||
|
|
||||||
BOOL CNetDatagramSender::SetSocket(const char * c_szIP, WORD wPortIndex)
|
BOOL CNetDatagramSender::SetSocket(const char * c_szIP, WORD wPortIndex)
|
||||||
{
|
{
|
||||||
@@ -8,12 +11,19 @@ BOOL CNetDatagramSender::SetSocket(const char * c_szIP, WORD wPortIndex)
|
|||||||
|
|
||||||
BOOL CNetDatagramSender::SetSocket(DWORD dwAddress, WORD wPortIndex)
|
BOOL CNetDatagramSender::SetSocket(DWORD dwAddress, WORD wPortIndex)
|
||||||
{
|
{
|
||||||
|
if (m_isSocket) {
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
mt::meshport::forget_socket(m_Socket);
|
||||||
|
#endif
|
||||||
|
closesocket(m_Socket);
|
||||||
|
}
|
||||||
m_isSocket = TRUE;
|
m_isSocket = TRUE;
|
||||||
|
|
||||||
m_dwAddress = dwAddress;
|
m_dwAddress = dwAddress;
|
||||||
m_wPortIndex = wPortIndex;
|
m_wPortIndex = wPortIndex;
|
||||||
|
|
||||||
m_Socket = socket(AF_INET, SOCK_DGRAM, 0);
|
m_Socket = socket(AF_INET, SOCK_DGRAM, 0);
|
||||||
|
if (m_Socket == INVALID_SOCKET) { m_isSocket = FALSE; return FALSE; }
|
||||||
|
|
||||||
memset(&m_SockAddr, 0, sizeof(SOCKADDR_IN));
|
memset(&m_SockAddr, 0, sizeof(SOCKADDR_IN));
|
||||||
m_SockAddr.sin_family = AF_INET;
|
m_SockAddr.sin_family = AF_INET;
|
||||||
@@ -27,7 +37,11 @@ BOOL CNetDatagramSender::Send(const void * pBuffer, int iSize)
|
|||||||
{
|
{
|
||||||
assert(isSocket());
|
assert(isSocket());
|
||||||
|
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
int iSendingLength = mt::meshport::send_datagram(m_Socket,pBuffer,iSize,0,m_SockAddr);
|
||||||
|
#else
|
||||||
int iSendingLength = sendto(m_Socket, (const char *)pBuffer, iSize, 0, (PSOCKADDR)&m_SockAddr, sizeof(SOCKADDR_IN));
|
int iSendingLength = sendto(m_Socket, (const char *)pBuffer, iSize, 0, (PSOCKADDR)&m_SockAddr, sizeof(SOCKADDR_IN));
|
||||||
|
#endif
|
||||||
if (iSendingLength < 0)
|
if (iSendingLength < 0)
|
||||||
{
|
{
|
||||||
Tracef("Failed sending packet\n");
|
Tracef("Failed sending packet\n");
|
||||||
@@ -55,4 +69,10 @@ CNetDatagramSender::CNetDatagramSender()
|
|||||||
|
|
||||||
CNetDatagramSender::~CNetDatagramSender()
|
CNetDatagramSender::~CNetDatagramSender()
|
||||||
{
|
{
|
||||||
|
if (m_isSocket) {
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
mt::meshport::forget_socket(m_Socket);
|
||||||
|
#endif
|
||||||
|
closesocket(m_Socket); // PORT: release the UDP socket and its mesh association together.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
#include "StdAfx.h"
|
#include "StdAfx.h"
|
||||||
#include "NetStream.h"
|
#include "NetStream.h"
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
#include "transport.h" // PORT: application-only mesh transport, including account/warp/mark sockets.
|
||||||
|
#endif
|
||||||
//#include "eterCrypt.h"
|
//#include "eterCrypt.h"
|
||||||
|
|
||||||
#ifndef _IMPROVED_PACKET_ENCRYPTION_
|
#ifndef _IMPROVED_PACKET_ENCRYPTION_
|
||||||
@@ -288,6 +291,19 @@ void CNetworkStream::Process()
|
|||||||
if (m_sock == INVALID_SOCKET)
|
if (m_sock == INVALID_SOCKET)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
if (!m_isOnline && mt::meshport::pending(m_sock))
|
||||||
|
{
|
||||||
|
const int state = mt::meshport::poll_socket(m_sock);
|
||||||
|
if (state < 0 || time(NULL) > m_connectLimitTime)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
OnConnectFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state == 0) return;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
fd_set fdsRecv;
|
fd_set fdsRecv;
|
||||||
fd_set fdsSend;
|
fd_set fdsSend;
|
||||||
|
|
||||||
@@ -373,6 +389,9 @@ void CNetworkStream::Clear()
|
|||||||
m_cipher.CleanUp();
|
m_cipher.CleanUp();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
mt::meshport::forget_socket(m_sock); // PORT: discard partial SOCKS negotiation before fd reuse.
|
||||||
|
#endif
|
||||||
closesocket(m_sock);
|
closesocket(m_sock);
|
||||||
m_sock = INVALID_SOCKET;
|
m_sock = INVALID_SOCKET;
|
||||||
|
|
||||||
@@ -416,7 +435,12 @@ bool CNetworkStream::Connect(const CNetworkAddress& c_rkNetAddr, int limitSec)
|
|||||||
DWORD arg = 1;
|
DWORD arg = 1;
|
||||||
ioctlsocket(m_sock, FIONBIO, &arg); // Non-blocking mode
|
ioctlsocket(m_sock, FIONBIO, &arg); // Non-blocking mode
|
||||||
|
|
||||||
if (connect(m_sock, (PSOCKADDR)&m_addr, m_addr.GetSize()) == SOCKET_ERROR)
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
const int connectResult = mt::meshport::connect_socket(m_sock, static_cast<const SOCKADDR_IN&>(m_addr), this);
|
||||||
|
#else
|
||||||
|
const int connectResult = connect(m_sock, (PSOCKADDR)&m_addr, m_addr.GetSize());
|
||||||
|
#endif
|
||||||
|
if (connectResult == SOCKET_ERROR)
|
||||||
{
|
{
|
||||||
int error = WSAGetLastError();
|
int error = WSAGetLastError();
|
||||||
|
|
||||||
@@ -430,6 +454,9 @@ bool CNetworkStream::Connect(const CNetworkAddress& c_rkNetAddr, int limitSec)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
if (mt::meshport::pending(m_sock) && limitSec < 75) limitSec = 75; // PORT: DHCP/routes plus remote SOCKS connect.
|
||||||
|
#endif
|
||||||
m_connectLimitTime = time(NULL) + limitSec;
|
m_connectLimitTime = time(NULL) + limitSec;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
#include "StdAfx.h"
|
||||||
|
#include "CRenderTarget.h"
|
||||||
|
#include "InstanceBase.h"
|
||||||
|
|
||||||
|
#include "../EterLib/Camera.h"
|
||||||
|
#include "../EterLib/GrpImageInstance.h"
|
||||||
|
#include "../EterLib/ResourceManager.h"
|
||||||
|
#include "../EterLib/StateManager.h"
|
||||||
|
#include "../EterPythonLib/PythonGraphic.h"
|
||||||
|
|
||||||
|
CRenderTarget::~CRenderTarget() {}
|
||||||
|
|
||||||
|
CRenderTarget::CRenderTarget(const DWORD width, const DWORD height) :
|
||||||
|
m_pModel(nullptr),
|
||||||
|
m_background(nullptr),
|
||||||
|
m_modelRotation(0),
|
||||||
|
m_pmodelRotationActive(true),
|
||||||
|
m_visible(false),
|
||||||
|
m_bShowModel(true),
|
||||||
|
m_bScale(1.0),
|
||||||
|
m_v3AngularVelocity(0.0f, 0.0f, 0.0f),
|
||||||
|
m_v3Eye(0.0f, 0.0f, 0.0f),
|
||||||
|
m_v3Target(0.0f, 0.0f, 0.0f),
|
||||||
|
m_v3Up(0.0f, 0.0f, 1.0f)
|
||||||
|
{
|
||||||
|
auto pTex = std::make_unique<CGraphicRenderTargetTexture>();
|
||||||
|
if (!pTex->Create(width, height, D3DFMT_X8R8G8B8, D3DFMT_D16))
|
||||||
|
{
|
||||||
|
TraceError("CRenderTarget::CRenderTarget: Could not create CGraphicRenderTargetTexture %dx%d", width, height);
|
||||||
|
throw std::runtime_error("CRenderTarget::CRenderTarget: Could not create CGraphicRenderTargetTexture");
|
||||||
|
}
|
||||||
|
|
||||||
|
m_renderTargetTexture = std::move(pTex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::RenderTexture() const
|
||||||
|
{
|
||||||
|
m_renderTargetTexture->Render();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::SetRenderingRect(RECT* rect) const
|
||||||
|
{
|
||||||
|
m_renderTargetTexture->SetRenderingRect(rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::CreateTextures() const
|
||||||
|
{
|
||||||
|
m_renderTargetTexture->CreateTextures();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::ReleaseTextures() const
|
||||||
|
{
|
||||||
|
m_renderTargetTexture->ReleaseTextures();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::ResetModel()
|
||||||
|
{
|
||||||
|
// PORT: AVA2 also cleared its wiki affects here; the port has none.
|
||||||
|
m_pModel.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::SetScale(float fScale)
|
||||||
|
{
|
||||||
|
m_bScale = fScale;
|
||||||
|
if (IsReady())
|
||||||
|
SelectModel(m_pModel->GetRace(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::SelectModel(const int index, bool isFromScale)
|
||||||
|
{
|
||||||
|
if (m_pModel && !isFromScale && m_pModel->GetRace() == index)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (isFromScale && m_pModel)
|
||||||
|
m_pModel.reset();
|
||||||
|
|
||||||
|
CInstanceBase::SCreateData kCreateData{};
|
||||||
|
// PORT: AVA2 created every model as TYPE_NPC. Here CActorInstance reserves the armor/weapon/hair parts for
|
||||||
|
// TYPE_PC only, so a player race created as an NPC crashes on the first ChangeArmor.
|
||||||
|
kCreateData.m_bType = index < NRaceData::JOB_MAX_NUM * 2 ? CActorInstance::TYPE_PC : CActorInstance::TYPE_NPC;
|
||||||
|
kCreateData.m_dwRace = index;
|
||||||
|
auto model = std::make_unique<CInstanceBase>();
|
||||||
|
if (!model->Create(kCreateData))
|
||||||
|
{
|
||||||
|
ResetModel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_pModel = std::move(model);
|
||||||
|
// PORT: the preview has no name; the empty text tail Create attaches would follow the world origin.
|
||||||
|
m_pModel->DetachTextTail();
|
||||||
|
m_pModel->NEW_SetPixelPosition(TPixelPosition(0, 0, 0));
|
||||||
|
m_pModel->SetRotation(0.0f);
|
||||||
|
|
||||||
|
float fTargetHeight = 110.0f;
|
||||||
|
|
||||||
|
m_v3Eye.x = 0.0f;
|
||||||
|
m_v3Eye.y = -1000.0f;
|
||||||
|
m_v3Eye.z = 600.0f;
|
||||||
|
|
||||||
|
m_v3Target.x = 0.0f;
|
||||||
|
m_v3Target.y = 0.0f;
|
||||||
|
m_v3Target.z = 95.0f;
|
||||||
|
|
||||||
|
if (index > (NRaceData::JOB_MAX_NUM * 2))
|
||||||
|
{
|
||||||
|
const auto ptrGraphicInstance = m_pModel->GetGraphicThingInstancePtr();
|
||||||
|
if (ptrGraphicInstance) {
|
||||||
|
const float fRaceHeight = (ptrGraphicInstance->GetHeight()/m_bScale);
|
||||||
|
fTargetHeight = fRaceHeight / 2.0f;
|
||||||
|
m_v3Eye.y = -(fRaceHeight * 8.9f);
|
||||||
|
m_v3Eye.z = 0.0f;
|
||||||
|
m_v3Target.z = fRaceHeight / 2.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto& camera_manager = CCameraManager::instance();
|
||||||
|
camera_manager.SetCurrentCamera(CCameraManager::DEFAULT_ORTHO_CAMERA);
|
||||||
|
camera_manager.GetCurrentCamera()->SetTargetHeight(fTargetHeight);
|
||||||
|
camera_manager.ResetToPreviousCamera();
|
||||||
|
m_modelRotation = 0.0f;
|
||||||
|
m_v3AngularVelocity = D3DXVECTOR3(0.0f, 0.0f, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CRenderTarget::CreateBackground(const char* imgPath, const DWORD width, const DWORD height)
|
||||||
|
{
|
||||||
|
if (m_background)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
m_background = std::make_unique<CGraphicImageInstance>();
|
||||||
|
|
||||||
|
const auto graphic_image = dynamic_cast<CGraphicImage*>(CResourceManager::instance().GetResourcePointer(imgPath));
|
||||||
|
if (!graphic_image)
|
||||||
|
{
|
||||||
|
m_background.reset();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_background->SetImagePointer(graphic_image);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PORT: AVA2 drew its background in a pass of its own through CGraphicImageInstance::Render. The port's image
|
||||||
|
// instance goes to the UI command list, not the device, so RenderModel draws the background into the target
|
||||||
|
// itself: one textured quad that fills it under a target-sized ortho.
|
||||||
|
void CRenderTarget::RenderBackground() const
|
||||||
|
{
|
||||||
|
if (!m_background || m_background->IsEmpty())
|
||||||
|
return;
|
||||||
|
CGraphicTexture* texture = m_background->GetTexturePointer();
|
||||||
|
if (!texture)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const auto& rectRender = *m_renderTargetTexture->GetRenderingRect();
|
||||||
|
const auto width = static_cast<float>(rectRender.right - rectRender.left);
|
||||||
|
const auto height = static_cast<float>(rectRender.bottom - rectRender.top);
|
||||||
|
|
||||||
|
auto& python_graphic = CPythonGraphic::Instance();
|
||||||
|
python_graphic.PushState();
|
||||||
|
STATEMANAGER.SaveTransform(D3DTS_WORLD, &CGraphicBase::GetIdentityMatrix());
|
||||||
|
STATEMANAGER.SetTransform(D3DTS_VIEW, &CGraphicBase::GetIdentityMatrix());
|
||||||
|
python_graphic.SetOrtho2D(width, height, 1.0f);
|
||||||
|
|
||||||
|
STATEMANAGER.SaveRenderState(D3DRS_ZENABLE, FALSE);
|
||||||
|
STATEMANAGER.SaveRenderState(D3DRS_ZWRITEENABLE, FALSE);
|
||||||
|
STATEMANAGER.SaveRenderState(D3DRS_LIGHTING, FALSE);
|
||||||
|
STATEMANAGER.SaveRenderState(D3DRS_FOGENABLE, FALSE);
|
||||||
|
STATEMANAGER.SaveRenderState(D3DRS_ALPHABLENDENABLE, FALSE);
|
||||||
|
STATEMANAGER.SaveRenderState(D3DRS_ALPHATESTENABLE, FALSE);
|
||||||
|
STATEMANAGER.SaveRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
|
||||||
|
STATEMANAGER.SaveTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
|
||||||
|
STATEMANAGER.SaveTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
|
||||||
|
STATEMANAGER.SaveTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_DISABLE);
|
||||||
|
STATEMANAGER.SaveTextureStageState(0, D3DTSS_MINFILTER, D3DTEXF_LINEAR);
|
||||||
|
STATEMANAGER.SaveTextureStageState(0, D3DTSS_MAGFILTER, D3DTEXF_LINEAR);
|
||||||
|
STATEMANAGER.SaveTextureStageState(0, D3DTSS_MIPFILTER, D3DTEXF_NONE);
|
||||||
|
STATEMANAGER.SaveTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE);
|
||||||
|
|
||||||
|
TPDTVertex vertices[4];
|
||||||
|
const float x[4] = { 0.0f, width, 0.0f, width };
|
||||||
|
const float y[4] = { 0.0f, 0.0f, height, height };
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
{
|
||||||
|
vertices[i].position = TPosition(x[i], y[i], 0.0f);
|
||||||
|
vertices[i].texCoord = TTextureCoordinate(x[i] / width, y[i] / height);
|
||||||
|
vertices[i].diffuse = 0xffffffff;
|
||||||
|
}
|
||||||
|
STATEMANAGER.SetTexture(0, texture->GetD3DTexture());
|
||||||
|
STATEMANAGER.SetTexture(1, nullptr);
|
||||||
|
STATEMANAGER.SetVertexShader(D3DFVF_XYZ | D3DFVF_TEX1 | D3DFVF_DIFFUSE);
|
||||||
|
STATEMANAGER.DrawPrimitiveUP(D3DPT_TRIANGLESTRIP, 2, vertices, sizeof(TPDTVertex));
|
||||||
|
|
||||||
|
STATEMANAGER.RestoreTextureStageState(1, D3DTSS_COLOROP);
|
||||||
|
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MIPFILTER);
|
||||||
|
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MAGFILTER);
|
||||||
|
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_MINFILTER);
|
||||||
|
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_ALPHAOP);
|
||||||
|
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_COLOROP);
|
||||||
|
STATEMANAGER.RestoreTextureStageState(0, D3DTSS_COLORARG1);
|
||||||
|
STATEMANAGER.RestoreRenderState(D3DRS_CULLMODE);
|
||||||
|
STATEMANAGER.RestoreRenderState(D3DRS_ALPHATESTENABLE);
|
||||||
|
STATEMANAGER.RestoreRenderState(D3DRS_ALPHABLENDENABLE);
|
||||||
|
STATEMANAGER.RestoreRenderState(D3DRS_FOGENABLE);
|
||||||
|
STATEMANAGER.RestoreRenderState(D3DRS_LIGHTING);
|
||||||
|
STATEMANAGER.RestoreRenderState(D3DRS_ZWRITEENABLE);
|
||||||
|
STATEMANAGER.RestoreRenderState(D3DRS_ZENABLE);
|
||||||
|
STATEMANAGER.RestoreTransform(D3DTS_WORLD);
|
||||||
|
python_graphic.PopState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::UpdateModel()
|
||||||
|
{
|
||||||
|
if (!IsReady())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (m_pmodelRotationActive && m_bShowModel)
|
||||||
|
{
|
||||||
|
if (m_modelRotation < 360.0f)
|
||||||
|
m_modelRotation += 1.0f / 60 * 60.0f;
|
||||||
|
else
|
||||||
|
m_modelRotation = 0.0f / 60 * 60.0f;
|
||||||
|
|
||||||
|
m_pModel->SetRotation(m_modelRotation);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
m_pModel->SetRotation(0.0f);
|
||||||
|
m_pModel->Transform();
|
||||||
|
if(m_pmodelRotationActive)
|
||||||
|
m_pModel->GetGraphicThingInstanceRef().RotationProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::DeformModel()
|
||||||
|
{
|
||||||
|
if (!IsReady())
|
||||||
|
return;
|
||||||
|
// PORT: stands in for AVA2's SetAlwaysRender. The model sits at the world origin, so the culling pass hides it.
|
||||||
|
m_pModel->GetGraphicThingInstanceRef().Show();
|
||||||
|
m_pModel->Deform();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::RenderModel()
|
||||||
|
{
|
||||||
|
if (!IsReady())
|
||||||
|
return;
|
||||||
|
|
||||||
|
auto& python_graphic = CPythonGraphic::Instance();
|
||||||
|
auto& camera_manager = CCameraManager::instance();
|
||||||
|
auto& state_manager = CStateManager::Instance();
|
||||||
|
auto& rectRender = *m_renderTargetTexture->GetRenderingRect();
|
||||||
|
|
||||||
|
const auto width = static_cast<float>(rectRender.right - rectRender.left);
|
||||||
|
const auto height = static_cast<float>(rectRender.bottom - rectRender.top);
|
||||||
|
// PORT: nothing to draw before the window has given its rect.
|
||||||
|
if (width <= 0.0f || height <= 0.0f)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_renderTargetTexture->SetRenderTarget();
|
||||||
|
m_renderTargetTexture->Clear();
|
||||||
|
python_graphic.SetViewport(0.0f, 0.0f, width, height);
|
||||||
|
RenderBackground();
|
||||||
|
|
||||||
|
python_graphic.ClearDepthBuffer();
|
||||||
|
|
||||||
|
const auto fov = python_graphic.GetFOV();
|
||||||
|
const auto aspect = python_graphic.GetAspect();
|
||||||
|
float near_y, far_y;
|
||||||
|
python_graphic.GetClipPlane(&near_y, &far_y);
|
||||||
|
|
||||||
|
state_manager.SetRenderState(D3DRS_FOGENABLE, FALSE);
|
||||||
|
|
||||||
|
python_graphic.PushState();
|
||||||
|
|
||||||
|
camera_manager.SetCurrentCamera(CCameraManager::DEFAULT_ORTHO_CAMERA);
|
||||||
|
camera_manager.GetCurrentCamera()->SetViewParams(m_v3Eye, m_v3Target, m_v3Up);
|
||||||
|
camera_manager.GetCurrentCamera()->RotateEyeAroundTarget(m_v3AngularVelocity.z, m_v3AngularVelocity.x);
|
||||||
|
|
||||||
|
python_graphic.UpdateViewMatrix();
|
||||||
|
python_graphic.SetPerspective(10.0f, width / height, 100.0f, 15000.0f);
|
||||||
|
|
||||||
|
if (m_bShowModel)
|
||||||
|
{
|
||||||
|
m_pModel->GetGraphicThingInstanceRef().Show();
|
||||||
|
m_pModel->Update();
|
||||||
|
m_pModel->Render();
|
||||||
|
}
|
||||||
|
// PORT: AVA2's RenderAllAttachingEffectWiki (attached effects) is not ported.
|
||||||
|
|
||||||
|
camera_manager.ResetToPreviousCamera();
|
||||||
|
python_graphic.RestoreViewport();
|
||||||
|
python_graphic.PopState();
|
||||||
|
python_graphic.SetPerspective(fov, aspect, near_y, far_y);
|
||||||
|
|
||||||
|
m_renderTargetTexture->ResetRenderTarget();
|
||||||
|
|
||||||
|
state_manager.SetRenderState(D3DRS_FOGENABLE, TRUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::SetModelV3Eye(float x, float y, float z)
|
||||||
|
{
|
||||||
|
m_v3Eye.x = x;
|
||||||
|
m_v3Eye.y = y;
|
||||||
|
m_v3Eye.z = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::SetModelV3Target(float x, float y, float z)
|
||||||
|
{
|
||||||
|
m_v3Target.x = x;
|
||||||
|
m_v3Target.y = y;
|
||||||
|
m_v3Target.z = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::RotateEyeAroundTarget(float fPitchDegree, float fRollDegree)
|
||||||
|
{
|
||||||
|
m_v3AngularVelocity.x = fRollDegree;
|
||||||
|
m_v3AngularVelocity.z = fPitchDegree;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CRenderTarget::Zoom(float fRatio)
|
||||||
|
{
|
||||||
|
if (fRatio == 1.0f)
|
||||||
|
return;
|
||||||
|
D3DXVECTOR3 v3Temp = m_v3Eye - m_v3Target;
|
||||||
|
v3Temp *= fRatio;
|
||||||
|
m_v3Eye = v3Temp + m_v3Target;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
// PORT: AVA2 CRenderTarget (EterLib/CRenderTarget.h): one model drawn into its own colour render target, which
|
||||||
|
// a UI window then shows. It lives here, not in EterLib, because it owns a CInstanceBase.
|
||||||
|
#include <memory>
|
||||||
|
#include "../EterLib/GrpRenderTargetTexture.h"
|
||||||
|
|
||||||
|
class CInstanceBase;
|
||||||
|
class CGraphicImageInstance;
|
||||||
|
|
||||||
|
class CRenderTarget
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
CRenderTarget(DWORD width, DWORD height);
|
||||||
|
~CRenderTarget();
|
||||||
|
|
||||||
|
void SetVisibility(bool isShow){ m_visible = isShow; }
|
||||||
|
void SetRotation(bool bRot) { m_pmodelRotationActive = bRot; }
|
||||||
|
bool IsReady() { return (m_visible && m_pModel); }
|
||||||
|
std::unique_ptr<CInstanceBase>& GetModel() { return m_pModel; }
|
||||||
|
|
||||||
|
void RotateEyeAroundTarget(float fPitchDegree, float fRollDegree);
|
||||||
|
void Zoom(float fRatio);
|
||||||
|
|
||||||
|
void SelectModel(int index, bool isFromScale= false);
|
||||||
|
void ResetModel();
|
||||||
|
|
||||||
|
void UpdateModel();
|
||||||
|
void DeformModel();
|
||||||
|
void RenderModel();
|
||||||
|
void SetRenderingRect(RECT* rect) const;
|
||||||
|
|
||||||
|
void CreateTextures() const;
|
||||||
|
void ReleaseTextures() const;
|
||||||
|
void RenderTexture() const;
|
||||||
|
|
||||||
|
bool CreateBackground(const char* imgPath, DWORD width, DWORD height);
|
||||||
|
void RenderBackground() const;
|
||||||
|
|
||||||
|
void SetModelV3Eye(float x, float y, float z);
|
||||||
|
void SetModelV3Target(float x, float y, float z);
|
||||||
|
|
||||||
|
void SetModelStatus(bool bStatus) { m_bShowModel = bStatus; }
|
||||||
|
|
||||||
|
float GetScale() { return m_bScale; }
|
||||||
|
void SetScale(float fScale);
|
||||||
|
|
||||||
|
bool IsShow() { return m_visible; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::unique_ptr<CInstanceBase> m_pModel;
|
||||||
|
std::unique_ptr<CGraphicImageInstance> m_background;
|
||||||
|
std::unique_ptr<CGraphicRenderTargetTexture> m_renderTargetTexture;
|
||||||
|
float m_modelRotation;
|
||||||
|
bool m_pmodelRotationActive;
|
||||||
|
bool m_visible;
|
||||||
|
bool m_bShowModel;
|
||||||
|
float m_bScale;
|
||||||
|
D3DXVECTOR3 m_v3AngularVelocity;
|
||||||
|
D3DXVECTOR3 m_v3Eye;
|
||||||
|
D3DXVECTOR3 m_v3Target;
|
||||||
|
D3DXVECTOR3 m_v3Up;
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#include "StdAfx.h"
|
||||||
|
#include "CRenderTargetManager.h"
|
||||||
|
#include "../EterBase/Stl.h"
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
CRenderTargetManager::CRenderTargetManager(){}
|
||||||
|
CRenderTargetManager::~CRenderTargetManager()
|
||||||
|
{
|
||||||
|
Destroy();
|
||||||
|
}
|
||||||
|
void CRenderTargetManager::Destroy()
|
||||||
|
{
|
||||||
|
m_renderTargets.clear();
|
||||||
|
}
|
||||||
|
void CRenderTargetManager::DeformModels()
|
||||||
|
{
|
||||||
|
for (const auto& elem : m_renderTargets)
|
||||||
|
elem.second->DeformModel();
|
||||||
|
}
|
||||||
|
void CRenderTargetManager::UpdateModels()
|
||||||
|
{
|
||||||
|
for (auto& elem : m_renderTargets)
|
||||||
|
elem.second->UpdateModel();
|
||||||
|
}
|
||||||
|
// PORT: the background is drawn into the target by RenderModel (CRenderTarget::RenderBackground).
|
||||||
|
void CRenderTargetManager::RenderBackgrounds()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
void CRenderTargetManager::RenderModels()
|
||||||
|
{
|
||||||
|
for (const auto& elem : m_renderTargets)
|
||||||
|
elem.second->RenderModel();
|
||||||
|
}
|
||||||
|
void CRenderTargetManager::CreateRenderTargetTextures()
|
||||||
|
{
|
||||||
|
for (const auto& elem : m_renderTargets)
|
||||||
|
elem.second->CreateTextures();
|
||||||
|
}
|
||||||
|
void CRenderTargetManager::ReleaseRenderTargetTextures()
|
||||||
|
{
|
||||||
|
for (const auto& elem : m_renderTargets)
|
||||||
|
elem.second->ReleaseTextures();
|
||||||
|
}
|
||||||
|
bool CRenderTargetManager::CreateRenderTarget(const int index, const int width, const int height)
|
||||||
|
{
|
||||||
|
// PORT: the size check guards the recording device, which allocates the target's bytes up front.
|
||||||
|
if (index < 1 || GetRenderTarget(index) || width < 1 || height < 1 || width > 2048 || height > 2048)
|
||||||
|
return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
m_renderTargets.emplace(index, std::make_shared<CRenderTarget>(width, height));
|
||||||
|
}
|
||||||
|
catch (const std::exception&)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void CRenderTargetManager::RemoveRenderTarget(const int index)
|
||||||
|
{
|
||||||
|
const auto it = m_renderTargets.find(index);
|
||||||
|
if (it != m_renderTargets.end())
|
||||||
|
{
|
||||||
|
it->second->ResetModel();
|
||||||
|
m_renderTargets.erase(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::shared_ptr<CRenderTarget> CRenderTargetManager::GetRenderTarget(const int index)
|
||||||
|
{
|
||||||
|
const auto it = m_renderTargets.find(index);
|
||||||
|
if (it != m_renderTargets.end())
|
||||||
|
return it->second;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
// PORT: AVA2 CRenderTargetManager (EterLib/CRenderTargetManager.h).
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "../EterBase/Singleton.h"
|
||||||
|
#include <memory>
|
||||||
|
#include "CRenderTarget.h"
|
||||||
|
|
||||||
|
class CRenderTargetManager : public CSingleton<CRenderTargetManager>
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
CRenderTargetManager();
|
||||||
|
virtual ~CRenderTargetManager();
|
||||||
|
|
||||||
|
std::shared_ptr<CRenderTarget> GetRenderTarget(int index);
|
||||||
|
bool CreateRenderTarget(int index, int width, int height);
|
||||||
|
|
||||||
|
void CreateRenderTargetTextures();
|
||||||
|
void ReleaseRenderTargetTextures();
|
||||||
|
|
||||||
|
void RemoveRenderTarget(const int index);
|
||||||
|
void Destroy();
|
||||||
|
void DeformModels();
|
||||||
|
void UpdateModels();
|
||||||
|
void RenderBackgrounds();
|
||||||
|
void RenderModels();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
std::unordered_map<int, std::shared_ptr<CRenderTarget>> m_renderTargets;
|
||||||
|
};
|
||||||
@@ -2472,11 +2472,24 @@ void CInstanceBase::__UpdateEffectShown()
|
|||||||
else
|
else
|
||||||
isShown = false;
|
isShown = false;
|
||||||
}
|
}
|
||||||
m_GraphicThingInstance.SetAttachingEffectShown(isShown);
|
m_GraphicThingInstance.SetAttachingEffectShown(isShown && PORT_IsDisplayVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CInstanceBase::PORT_IsDisplayVisible()
|
||||||
|
{
|
||||||
|
// AVA2 CActorInstance::IsShop and InstanceBase::__CanRender, including
|
||||||
|
// the original 100/60/40/20/10 metre range presets. Never hide the main actor.
|
||||||
|
CInstanceBase* main = __GetMainInstancePtr();
|
||||||
|
if (!main || main == this || GetRace() < 30000 || GetRace() > 30020)
|
||||||
|
return true;
|
||||||
|
static const unsigned ranges[] = {10000, 6000, 4000, 2000, 1000};
|
||||||
|
return NEW_GetDistanceFromDestInstance(*main) <= ranges[CPythonSystem::Instance().GetShopNamesRange()];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool CInstanceBase::__CanRender()
|
bool CInstanceBase::__CanRender()
|
||||||
{
|
{
|
||||||
|
if (!PORT_IsDisplayVisible())
|
||||||
|
return false;
|
||||||
if (!__IsInViewFrustum())
|
if (!__IsInViewFrustum())
|
||||||
return false;
|
return false;
|
||||||
if (IsAffect(AFFECT_INVISIBILITY))
|
if (IsAffect(AFFECT_INVISIBILITY))
|
||||||
|
|||||||
@@ -398,6 +398,7 @@ class CInstanceBase
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
void SetMainInstance();
|
void SetMainInstance();
|
||||||
|
bool PORT_IsDisplayVisible();
|
||||||
|
|
||||||
void OnSelected();
|
void OnSelected();
|
||||||
void OnUnselected();
|
void OnUnselected();
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
#include <chrono>
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
#include "transport.h" // PORT: registration shares the same LAN/mesh policy as every game socket.
|
||||||
|
#endif
|
||||||
|
|
||||||
#include <arpa/inet.h>
|
#include <arpa/inet.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
@@ -35,6 +39,11 @@ const int c_iRegisterTimeoutMs = 8000;
|
|||||||
std::mutex gs_kRegisterMutex;
|
std::mutex gs_kRegisterMutex;
|
||||||
std::atomic<int> gs_iRegisterState(REGISTER_IDLE);
|
std::atomic<int> gs_iRegisterState(REGISTER_IDLE);
|
||||||
std::string gs_strRegisterResult;
|
std::string gs_strRegisterResult;
|
||||||
|
// PORT: registration must stop on dialog cancellation and before the mesh runtime is torn down.
|
||||||
|
std::thread gs_registerWorker;
|
||||||
|
std::atomic<bool> gs_registerCancelled(false);
|
||||||
|
std::mutex gs_registerSocketMutex;
|
||||||
|
int gs_registerSocket = -1;
|
||||||
|
|
||||||
std::string __UrlEncode(const std::string& c_rstText)
|
std::string __UrlEncode(const std::string& c_rstText)
|
||||||
{
|
{
|
||||||
@@ -58,8 +67,14 @@ std::string __UrlEncode(const std::string& c_rstText)
|
|||||||
|
|
||||||
bool __WaitFor(int iSocket, short sEvents)
|
bool __WaitFor(int iSocket, short sEvents)
|
||||||
{
|
{
|
||||||
pollfd kPoll = { iSocket, sEvents, 0 };
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(c_iRegisterTimeoutMs);
|
||||||
return poll(&kPoll, 1, c_iRegisterTimeoutMs) == 1 && (kPoll.revents & sEvents);
|
while (!gs_registerCancelled && std::chrono::steady_clock::now() < deadline) {
|
||||||
|
pollfd kPoll = {iSocket,sEvents,0};
|
||||||
|
int result = poll(&kPoll,1,100);
|
||||||
|
if (result > 0) return (kPoll.revents & sEvents) != 0;
|
||||||
|
if (result < 0 && errno != EINTR) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string __Post(const std::string& c_rstHost, int iPort, const std::string& c_rstBody)
|
std::string __Post(const std::string& c_rstHost, int iPort, const std::string& c_rstBody)
|
||||||
@@ -72,6 +87,7 @@ std::string __Post(const std::string& c_rstHost, int iPort, const std::string& c
|
|||||||
if (getaddrinfo(c_rstHost.c_str(), std::to_string(iPort).c_str(), &kHints, &pkAddr) != 0 || !pkAddr)
|
if (getaddrinfo(c_rstHost.c_str(), std::to_string(iPort).c_str(), &kHints, &pkAddr) != 0 || !pkAddr)
|
||||||
return "ERR CONNECT";
|
return "ERR CONNECT";
|
||||||
|
|
||||||
|
if (gs_registerCancelled) { freeaddrinfo(pkAddr); return "ERR CANCELLED"; }
|
||||||
const int iSocket = socket(pkAddr->ai_family, pkAddr->ai_socktype, pkAddr->ai_protocol);
|
const int iSocket = socket(pkAddr->ai_family, pkAddr->ai_socktype, pkAddr->ai_protocol);
|
||||||
if (iSocket < 0)
|
if (iSocket < 0)
|
||||||
{
|
{
|
||||||
@@ -82,14 +98,37 @@ std::string __Post(const std::string& c_rstHost, int iPort, const std::string& c
|
|||||||
const int iOne = 1;
|
const int iOne = 1;
|
||||||
setsockopt(iSocket, SOL_SOCKET, SO_NOSIGPIPE, &iOne, sizeof(iOne));
|
setsockopt(iSocket, SOL_SOCKET, SO_NOSIGPIPE, &iOne, sizeof(iOne));
|
||||||
#endif
|
#endif
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(gs_registerSocketMutex);
|
||||||
|
gs_registerSocket = iSocket;
|
||||||
|
}
|
||||||
fcntl(iSocket, F_SETFL, fcntl(iSocket, F_GETFL, 0) | O_NONBLOCK);
|
fcntl(iSocket, F_SETFL, fcntl(iSocket, F_GETFL, 0) | O_NONBLOCK);
|
||||||
|
|
||||||
std::string stResult = "ERR CONNECT";
|
std::string stResult = "ERR CONNECT";
|
||||||
int iError = 0;
|
int iError = 0;
|
||||||
socklen_t iErrorLen = sizeof(iError);
|
socklen_t iErrorLen = sizeof(iError);
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
const int connected = mt::meshport::connect_socket(iSocket, *reinterpret_cast<sockaddr_in*>(pkAddr->ai_addr));
|
||||||
|
const bool throughMesh = mt::meshport::pending(iSocket);
|
||||||
|
bool meshReady = true;
|
||||||
|
if (mt::meshport::pending(iSocket)) {
|
||||||
|
int state = 0;
|
||||||
|
while (state == 0 && !gs_registerCancelled) {
|
||||||
|
state = mt::meshport::poll_socket(iSocket);
|
||||||
|
if (!state) std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||||
|
}
|
||||||
|
meshReady = state == 1;
|
||||||
|
}
|
||||||
|
if (!gs_registerCancelled && meshReady && (throughMesh || connected == 0 ||
|
||||||
|
#else
|
||||||
if (connect(iSocket, pkAddr->ai_addr, pkAddr->ai_addrlen) == 0 ||
|
if (connect(iSocket, pkAddr->ai_addr, pkAddr->ai_addrlen) == 0 ||
|
||||||
|
#endif
|
||||||
(errno == EINPROGRESS && __WaitFor(iSocket, POLLOUT) &&
|
(errno == EINPROGRESS && __WaitFor(iSocket, POLLOUT) &&
|
||||||
getsockopt(iSocket, SOL_SOCKET, SO_ERROR, &iError, &iErrorLen) == 0 && iError == 0))
|
getsockopt(iSocket, SOL_SOCKET, SO_ERROR, &iError, &iErrorLen) == 0 && iError == 0)
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
)
|
||||||
{
|
{
|
||||||
const std::string stRequest =
|
const std::string stRequest =
|
||||||
"POST /register HTTP/1.0\r\n"
|
"POST /register HTTP/1.0\r\n"
|
||||||
@@ -140,9 +179,16 @@ std::string __Post(const std::string& c_rstHost, int iPort, const std::string& c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(iSocket);
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
mt::meshport::forget_socket(iSocket);
|
||||||
|
#endif
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(gs_registerSocketMutex);
|
||||||
|
gs_registerSocket = -1;
|
||||||
|
close(iSocket);
|
||||||
|
}
|
||||||
freeaddrinfo(pkAddr);
|
freeaddrinfo(pkAddr);
|
||||||
return stResult;
|
return gs_registerCancelled ? "ERR CANCELLED" : stResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,10 +218,12 @@ PyObject* netRegisterAccount(PyObject* poSelf, PyObject* poArgs)
|
|||||||
return Py_BuildValue("i", 0); // one request at a time
|
return Py_BuildValue("i", 0); // one request at a time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gs_registerWorker.joinable()) gs_registerWorker.join();
|
||||||
|
gs_registerCancelled = false;
|
||||||
const std::string stHost = szHost;
|
const std::string stHost = szHost;
|
||||||
const std::string stBody = "login=" + __UrlEncode(szLogin) + "&password=" + __UrlEncode(szPassword) +
|
const std::string stBody = "login=" + __UrlEncode(szLogin) + "&password=" + __UrlEncode(szPassword) +
|
||||||
"&social_id=" + __UrlEncode(szSocialID);
|
"&social_id=" + __UrlEncode(szSocialID);
|
||||||
std::thread([stHost, iPort, stBody]()
|
gs_registerWorker = std::thread([stHost, iPort, stBody]()
|
||||||
{
|
{
|
||||||
const std::string stResult = __Post(stHost, iPort, stBody);
|
const std::string stResult = __Post(stHost, iPort, stBody);
|
||||||
{
|
{
|
||||||
@@ -183,7 +231,7 @@ PyObject* netRegisterAccount(PyObject* poSelf, PyObject* poArgs)
|
|||||||
gs_strRegisterResult = stResult;
|
gs_strRegisterResult = stResult;
|
||||||
}
|
}
|
||||||
gs_iRegisterState = REGISTER_DONE;
|
gs_iRegisterState = REGISTER_DONE;
|
||||||
}).detach();
|
});
|
||||||
return Py_BuildValue("i", 1);
|
return Py_BuildValue("i", 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,3 +244,22 @@ PyObject* netGetRegisterAccountResult(PyObject* poSelf, PyObject* poArgs)
|
|||||||
std::lock_guard<std::mutex> kLock(gs_kRegisterMutex);
|
std::lock_guard<std::mutex> kLock(gs_kRegisterMutex);
|
||||||
return Py_BuildValue("s", gs_strRegisterResult.c_str());
|
return Py_BuildValue("s", gs_strRegisterResult.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PORT: cancellation interrupts both route preparation and HTTP I/O; the worker owns close().
|
||||||
|
void CancelAccountRegistration()
|
||||||
|
{
|
||||||
|
gs_registerCancelled = true;
|
||||||
|
std::lock_guard<std::mutex> lock(gs_registerSocketMutex);
|
||||||
|
if (gs_registerSocket >= 0) ::shutdown(gs_registerSocket,SHUT_RDWR);
|
||||||
|
}
|
||||||
|
void StopAccountRegistration()
|
||||||
|
{
|
||||||
|
CancelAccountRegistration();
|
||||||
|
if (gs_registerWorker.joinable()) gs_registerWorker.join();
|
||||||
|
gs_iRegisterState = REGISTER_IDLE;
|
||||||
|
}
|
||||||
|
PyObject* netCancelRegisterAccount(PyObject*,PyObject*)
|
||||||
|
{
|
||||||
|
CancelAccountRegistration();
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#include "StdAfx.h"
|
#include "StdAfx.h"
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
#include "transport.h" // PORT: cancel only this login's pending mesh transports.
|
||||||
|
#endif
|
||||||
#include "PythonNetworkStream.h"
|
#include "PythonNetworkStream.h"
|
||||||
//#include "PythonNetworkDatagram.h"
|
//#include "PythonNetworkDatagram.h"
|
||||||
#include "AccountConnector.h"
|
#include "AccountConnector.h"
|
||||||
@@ -14,6 +17,7 @@ std::list<std::string> g_kList_strCommand;
|
|||||||
// PORT: PythonAccountRegister.cpp
|
// PORT: PythonAccountRegister.cpp
|
||||||
PyObject* netRegisterAccount(PyObject* poSelf, PyObject* poArgs);
|
PyObject* netRegisterAccount(PyObject* poSelf, PyObject* poArgs);
|
||||||
PyObject* netGetRegisterAccountResult(PyObject* poSelf, PyObject* poArgs);
|
PyObject* netGetRegisterAccountResult(PyObject* poSelf, PyObject* poArgs);
|
||||||
|
PyObject* netCancelRegisterAccount(PyObject*,PyObject*); // PORT: cancel pending HTTP/mesh work.
|
||||||
|
|
||||||
PyObject* netGetBettingGuildWarValue(PyObject* poSelf, PyObject* poArgs)
|
PyObject* netGetBettingGuildWarValue(PyObject* poSelf, PyObject* poArgs)
|
||||||
{
|
{
|
||||||
@@ -1014,6 +1018,22 @@ PyObject* netLogOutGame(PyObject* poSelf, PyObject* poArgs)
|
|||||||
return Py_BuildNone();
|
return Py_BuildNone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
PyObject* netCancelPendingLogin(PyObject*,PyObject*)
|
||||||
|
{
|
||||||
|
bool cancelled = false;
|
||||||
|
auto& account = CAccountConnector::Instance();
|
||||||
|
auto& game = CPythonNetworkStream::Instance();
|
||||||
|
if (mt::meshport::pending_owner(static_cast<CNetworkStream*>(&account))) {
|
||||||
|
account.Disconnect(); cancelled = true;
|
||||||
|
}
|
||||||
|
if (mt::meshport::pending_owner(static_cast<CNetworkStream*>(&game))) {
|
||||||
|
game.Disconnect(); cancelled = true;
|
||||||
|
}
|
||||||
|
return PyBool_FromLong(cancelled);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
PyObject* netDisconnect(PyObject* poSelf, PyObject* poArgs)
|
PyObject* netDisconnect(PyObject* poSelf, PyObject* poArgs)
|
||||||
{
|
{
|
||||||
CPythonNetworkStream& rkNetStream=CPythonNetworkStream::Instance();
|
CPythonNetworkStream& rkNetStream=CPythonNetworkStream::Instance();
|
||||||
@@ -1774,6 +1794,10 @@ void initnet()
|
|||||||
{ "SendChangeNamePacket", netSendChangeNamePacket, METH_VARARGS },
|
{ "SendChangeNamePacket", netSendChangeNamePacket, METH_VARARGS },
|
||||||
{ "SendCreateCharacterPacket", netSendCreateCharacterPacket, METH_VARARGS },
|
{ "SendCreateCharacterPacket", netSendCreateCharacterPacket, METH_VARARGS },
|
||||||
{ "RegisterAccount", netRegisterAccount, METH_VARARGS },
|
{ "RegisterAccount", netRegisterAccount, METH_VARARGS },
|
||||||
|
{ "CancelRegisterAccount", netCancelRegisterAccount, METH_NOARGS },
|
||||||
|
#ifdef MT_HAS_MESHPORT
|
||||||
|
{ "CancelPendingLogin", netCancelPendingLogin, METH_NOARGS },
|
||||||
|
#endif
|
||||||
{ "GetRegisterAccountResult", netGetRegisterAccountResult, METH_VARARGS },
|
{ "GetRegisterAccountResult", netGetRegisterAccountResult, METH_VARARGS },
|
||||||
{ "SendDestroyCharacterPacket", netSendDestroyCharacterPacket, METH_VARARGS },
|
{ "SendDestroyCharacterPacket", netSendDestroyCharacterPacket, METH_VARARGS },
|
||||||
{ "SendEnterGamePacket", netSendEnterGamePacket, METH_VARARGS },
|
{ "SendEnterGamePacket", netSendEnterGamePacket, METH_VARARGS },
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// PORT: AVA2 CPythonRenderTargetModule (module renderTarget), the subset the model preview uses. AVA2's window
|
||||||
|
// manager created the target for a registered window and drew it; here Create and RenderTexture do that, called
|
||||||
|
// from the script's own window (ScriptLib/ModelPreviewScript.inc).
|
||||||
|
#include "StdAfx.h"
|
||||||
|
#include "CRenderTargetManager.h"
|
||||||
|
#include "InstanceBase.h"
|
||||||
|
#include "../GameLib/ItemManager.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// The default of CPythonApplication's camera zoom speed (PythonApplicationCamera.cpp), which AVA2 read.
|
||||||
|
const float c_fRenderTargetZoomSpeed = 0.05f;
|
||||||
|
|
||||||
|
std::shared_ptr<CRenderTarget> Target(PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int iIndex;
|
||||||
|
if (!PyTuple_GetInteger(poArgs, 0, &iIndex))
|
||||||
|
return nullptr;
|
||||||
|
return CRenderTargetManager::Instance().GetRenderTarget(iIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Integer(PyObject* poArgs, int pos, int* value)
|
||||||
|
{
|
||||||
|
return PyTuple_GetInteger(poArgs, pos, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetCreate(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int iIndex, iWidth, iHeight;
|
||||||
|
if (!Integer(poArgs, 0, &iIndex) || !Integer(poArgs, 1, &iWidth) || !Integer(poArgs, 2, &iHeight))
|
||||||
|
return Py_BadArgument();
|
||||||
|
return Py_BuildValue("i", CRenderTargetManager::Instance().CreateRenderTarget(iIndex, iWidth, iHeight) ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetRemove(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int iIndex;
|
||||||
|
if (!Integer(poArgs, 0, &iIndex))
|
||||||
|
return Py_BadArgument();
|
||||||
|
CRenderTargetManager::Instance().RemoveRenderTarget(iIndex);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetExists(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int iIndex;
|
||||||
|
if (!Integer(poArgs, 0, &iIndex))
|
||||||
|
return Py_BadArgument();
|
||||||
|
return Py_BuildValue("i", CRenderTargetManager::Instance().GetRenderTarget(iIndex) ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetRenderTexture(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int l, t, r, b;
|
||||||
|
if (!Integer(poArgs, 1, &l) || !Integer(poArgs, 2, &t) || !Integer(poArgs, 3, &r) || !Integer(poArgs, 4, &b))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target && target->IsShow())
|
||||||
|
{
|
||||||
|
RECT rect = { l, t, r, b };
|
||||||
|
target->SetRenderingRect(&rect);
|
||||||
|
target->RenderTexture();
|
||||||
|
}
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSelectModel(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int modelIndex;
|
||||||
|
if (!Integer(poArgs, 1, &modelIndex))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target)
|
||||||
|
target->SelectModel(modelIndex);
|
||||||
|
return Py_BuildValue("i", target && target->GetModel() ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetArmor(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int iItemVnum;
|
||||||
|
if (!Integer(poArgs, 1, &iItemVnum))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target && target->IsReady())
|
||||||
|
target->GetModel()->ChangeArmor(iItemVnum);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetWeapon(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int iItemVnum;
|
||||||
|
if (!Integer(poArgs, 1, &iItemVnum))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target && target->IsReady())
|
||||||
|
target->GetModel()->ChangeWeapon(iItemVnum);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetHair(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int iItemVnum;
|
||||||
|
if (!Integer(poArgs, 1, &iItemVnum))
|
||||||
|
return Py_BadArgument();
|
||||||
|
bool isValue = false;
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target)
|
||||||
|
{
|
||||||
|
PyTuple_GetBoolean(poArgs, 2, &isValue);
|
||||||
|
if (!isValue)
|
||||||
|
{
|
||||||
|
CItemData* pItemData;
|
||||||
|
if (!CItemManager::Instance().GetItemDataPointer(iItemVnum, &pItemData))
|
||||||
|
return Py_BuildNone();
|
||||||
|
iItemVnum = pItemData->GetValue(3);
|
||||||
|
}
|
||||||
|
if (target->IsReady())
|
||||||
|
target->GetModel()->SetHair(iItemVnum);
|
||||||
|
}
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetMount(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int iRace;
|
||||||
|
if (!Integer(poArgs, 1, &iRace))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target && target->IsReady())
|
||||||
|
target->GetModel()->MountHorse(iRace);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetRotation(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
bool bRot;
|
||||||
|
if (!PyTuple_GetBoolean(poArgs, 1, &bRot))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target)
|
||||||
|
target->SetRotation(bRot);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetVisibility(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
bool isShow;
|
||||||
|
if (!PyTuple_GetBoolean(poArgs, 1, &isShow))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target)
|
||||||
|
target->SetVisibility(isShow);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetBackground(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
char* szPathName;
|
||||||
|
if (!PyTuple_GetString(poArgs, 1, &szPathName))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
// PORT: the background is stretched over the target when drawn, so the sizes AVA2 passed are unused.
|
||||||
|
return Py_BuildValue("i", target && target->CreateBackground(szPathName, 0, 0) ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetResetModel(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target)
|
||||||
|
target->ResetModel();
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetDestroy(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
CRenderTargetManager::Instance().Destroy();
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetModelV3Eye(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
float x, y, z;
|
||||||
|
if (!PyTuple_GetFloat(poArgs, 1, &x) || !PyTuple_GetFloat(poArgs, 2, &y) || !PyTuple_GetFloat(poArgs, 3, &z))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target)
|
||||||
|
target->SetModelV3Eye(x, y, z);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetModelV3Target(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
float x, y, z;
|
||||||
|
if (!PyTuple_GetFloat(poArgs, 1, &x) || !PyTuple_GetFloat(poArgs, 2, &y) || !PyTuple_GetFloat(poArgs, 3, &z))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target)
|
||||||
|
target->SetModelV3Target(x, y, z);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetZoom(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int iDirection;
|
||||||
|
if (!Integer(poArgs, 1, &iDirection))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target && target->IsReady())
|
||||||
|
target->Zoom(1.0f + c_fRenderTargetZoomSpeed * float(iDirection));
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetRotateEyeAroundTarget(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
float fPitch, fRoll;
|
||||||
|
if (!PyTuple_GetFloat(poArgs, 1, &fPitch) || !PyTuple_GetFloat(poArgs, 2, &fRoll))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target && target->IsReady())
|
||||||
|
target->RotateEyeAroundTarget(fPitch, fRoll);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetScale(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
float fScale;
|
||||||
|
if (!PyTuple_GetFloat(poArgs, 1, &fScale))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target)
|
||||||
|
target->SetScale(fScale);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetIsShow(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
return Py_BuildValue("i", target ? target->IsShow() : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* renderTargetSetMotionIndex(PyObject* poSelf, PyObject* poArgs)
|
||||||
|
{
|
||||||
|
int iMotionIndex;
|
||||||
|
if (!Integer(poArgs, 1, &iMotionIndex))
|
||||||
|
return Py_BadArgument();
|
||||||
|
const auto target = Target(poArgs);
|
||||||
|
if (target && target->IsReady())
|
||||||
|
return Py_BuildValue("i", target->GetModel()->GetGraphicThingInstanceRef().InterceptOnceMotion(iMotionIndex) ? 1 : 0);
|
||||||
|
return Py_BuildValue("i", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void initRenderTarget()
|
||||||
|
{
|
||||||
|
static PyMethodDef s_methods[] =
|
||||||
|
{
|
||||||
|
{ "Create", renderTargetCreate, METH_VARARGS },
|
||||||
|
{ "Remove", renderTargetRemove, METH_VARARGS },
|
||||||
|
{ "Exists", renderTargetExists, METH_VARARGS },
|
||||||
|
{ "RenderTexture", renderTargetRenderTexture, METH_VARARGS },
|
||||||
|
{ "SelectModel", renderTargetSelectModel, METH_VARARGS },
|
||||||
|
{ "SetVisibility", renderTargetSetVisibility, METH_VARARGS },
|
||||||
|
{ "SetBackground", renderTargetSetBackground, METH_VARARGS },
|
||||||
|
{ "SetArmor", renderTargetSetArmor, METH_VARARGS },
|
||||||
|
{ "SetWeapon", renderTargetSetWeapon, METH_VARARGS },
|
||||||
|
{ "SetHair", renderTargetSetHair, METH_VARARGS },
|
||||||
|
{ "SetMount", renderTargetSetMount, METH_VARARGS },
|
||||||
|
{ "SetRotation", renderTargetSetRotation, METH_VARARGS },
|
||||||
|
{ "ResetModel", renderTargetResetModel, METH_VARARGS },
|
||||||
|
{ "Destroy", renderTargetDestroy, METH_VARARGS },
|
||||||
|
{ "SetModelV3Eye", renderTargetSetModelV3Eye, METH_VARARGS },
|
||||||
|
{ "SetModelV3Target", renderTargetSetModelV3Target, METH_VARARGS },
|
||||||
|
{ "Zoom", renderTargetZoom, METH_VARARGS },
|
||||||
|
{ "RotateEyeAroundTarget", renderTargetRotateEyeAroundTarget, METH_VARARGS },
|
||||||
|
{ "SetScale", renderTargetSetScale, METH_VARARGS },
|
||||||
|
{ "IsShow", renderTargetIsShow, METH_VARARGS },
|
||||||
|
{ "SetMotionIndex", renderTargetSetMotionIndex, METH_VARARGS },
|
||||||
|
{ NULL, NULL, 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
Py_InitModule("renderTarget", s_methods);
|
||||||
|
}
|
||||||
@@ -113,6 +113,10 @@ class CPythonSystem : public CSingleton<CPythonSystem>
|
|||||||
// PORT: AVA2 effect_level (0 all, 1 hide players/NPCs, 2 hide monsters/NPCs, 3 hide others, 4 hide all).
|
// PORT: AVA2 effect_level (0 all, 1 hide players/NPCs, 2 hide monsters/NPCs, 3 hide others, 4 hide all).
|
||||||
int GetEffectLevel() { return m_iPortEffectLevel; }
|
int GetEffectLevel() { return m_iPortEffectLevel; }
|
||||||
void SetEffectLevel(int iLevel) { m_iPortEffectLevel = iLevel < 0 || iLevel > 4 ? 0 : iLevel; }
|
void SetEffectLevel(int iLevel) { m_iPortEffectLevel = iLevel < 0 || iLevel > 4 ? 0 : iLevel; }
|
||||||
|
int GetShopNamesRange() { return m_iPortShopRange; }
|
||||||
|
void SetShopNamesRange(int index) { m_iPortShopRange = index < 0 || index > 4 ? 0 : index; }
|
||||||
|
bool GetShopNameStatus() { return m_bPortNPCNames; }
|
||||||
|
void SetShopNameStatus(bool shown) { m_bPortNPCNames = shown; }
|
||||||
bool IsUseDefaultIME();
|
bool IsUseDefaultIME();
|
||||||
bool IsNoSoundCard();
|
bool IsNoSoundCard();
|
||||||
bool IsAutoTiling();
|
bool IsAutoTiling();
|
||||||
@@ -163,4 +167,6 @@ class CPythonSystem : public CSingleton<CPythonSystem>
|
|||||||
PyObject * m_poInterfaceHandler;
|
PyObject * m_poInterfaceHandler;
|
||||||
TWindowStatus m_WindowStatus[WINDOW_MAX_NUM];
|
TWindowStatus m_WindowStatus[WINDOW_MAX_NUM];
|
||||||
int m_iPortEffectLevel = 0;
|
int m_iPortEffectLevel = 0;
|
||||||
|
int m_iPortShopRange = 0;
|
||||||
|
bool m_bPortNPCNames = true;
|
||||||
};
|
};
|
||||||
@@ -227,6 +227,29 @@ PyObject * systemGetEffectLevel(PyObject * poSelf, PyObject * poArgs)
|
|||||||
return Py_BuildValue("i", CPythonSystem::Instance().GetEffectLevel());
|
return Py_BuildValue("i", CPythonSystem::Instance().GetEffectLevel());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PyObject* systemSetShopNamesRange(PyObject*, PyObject* args)
|
||||||
|
{
|
||||||
|
int index;
|
||||||
|
if (!PyTuple_GetInteger(args, 0, &index)) return Py_BadArgument();
|
||||||
|
CPythonSystem::Instance().SetShopNamesRange(index);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
PyObject* systemGetShopNamesRange(PyObject*, PyObject*)
|
||||||
|
{
|
||||||
|
return Py_BuildValue("i", CPythonSystem::Instance().GetShopNamesRange());
|
||||||
|
}
|
||||||
|
PyObject* systemSetShopNameStatus(PyObject*, PyObject* args)
|
||||||
|
{
|
||||||
|
int shown;
|
||||||
|
if (!PyTuple_GetInteger(args, 0, &shown)) return Py_BadArgument();
|
||||||
|
CPythonSystem::Instance().SetShopNameStatus(shown != 0);
|
||||||
|
return Py_BuildNone();
|
||||||
|
}
|
||||||
|
PyObject* systemGetShopNameStatus(PyObject*, PyObject*)
|
||||||
|
{
|
||||||
|
return Py_BuildValue("i", CPythonSystem::Instance().GetShopNameStatus());
|
||||||
|
}
|
||||||
|
|
||||||
PyObject * systemIsAlwaysShowName(PyObject * poSelf, PyObject * poArgs)
|
PyObject * systemIsAlwaysShowName(PyObject * poSelf, PyObject * poArgs)
|
||||||
{
|
{
|
||||||
return Py_BuildValue("i", CPythonSystem::Instance().IsAlwaysShowName());
|
return Py_BuildValue("i", CPythonSystem::Instance().IsAlwaysShowName());
|
||||||
@@ -440,6 +463,10 @@ void initsystem()
|
|||||||
{ "IsShowSalesText", systemIsShowSalesText, METH_VARARGS },
|
{ "IsShowSalesText", systemIsShowSalesText, METH_VARARGS },
|
||||||
{ "SetEffectLevel", systemSetEffectLevel, METH_VARARGS }, // PORT: AVA2
|
{ "SetEffectLevel", systemSetEffectLevel, METH_VARARGS }, // PORT: AVA2
|
||||||
{ "GetEffectLevel", systemGetEffectLevel, METH_VARARGS },
|
{ "GetEffectLevel", systemGetEffectLevel, METH_VARARGS },
|
||||||
|
{ "SetShopNamesRange", systemSetShopNamesRange, METH_VARARGS },
|
||||||
|
{ "GetShopNamesRange", systemGetShopNamesRange, METH_VARARGS },
|
||||||
|
{ "SetShopNameStatus", systemSetShopNameStatus, METH_VARARGS },
|
||||||
|
{ "GetShopNameStatus", systemGetShopNameStatus, METH_VARARGS },
|
||||||
|
|
||||||
{ "GetShadowLevel", systemGetShadowLevel, METH_VARARGS },
|
{ "GetShadowLevel", systemGetShadowLevel, METH_VARARGS },
|
||||||
{ "SetShadowLevel", systemSetShadowLevel, METH_VARARGS },
|
{ "SetShadowLevel", systemSetShadowLevel, METH_VARARGS },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include "resource.h"
|
#include "resource.h"
|
||||||
#include "PythonTextTail.h"
|
#include "PythonTextTail.h"
|
||||||
#include "PythonCharacterManager.h"
|
#include "PythonCharacterManager.h"
|
||||||
|
#include "PythonSystem.h"
|
||||||
#include "PythonGuild.h"
|
#include "PythonGuild.h"
|
||||||
#include "Locale.h"
|
#include "Locale.h"
|
||||||
#include "MarkManager.h"
|
#include "MarkManager.h"
|
||||||
@@ -463,6 +464,11 @@ void CPythonTextTail::ShowCharacterTextTail(DWORD VirtualID)
|
|||||||
|
|
||||||
if (pInstance->IsGuildWall())
|
if (pInstance->IsGuildWall())
|
||||||
return;
|
return;
|
||||||
|
if (!pInstance->PORT_IsDisplayVisible())
|
||||||
|
return;
|
||||||
|
if (pInstance->IsNPC() && (pInstance->GetRace() < 30000 || pInstance->GetRace() > 30020) &&
|
||||||
|
!CPythonSystem::Instance().GetShopNameStatus())
|
||||||
|
return;
|
||||||
|
|
||||||
if (pInstance->CanPickInstance())
|
if (pInstance->CanPickInstance())
|
||||||
m_CharacterTextTailList.push_back(pTextTail);
|
m_CharacterTextTailList.push_back(pTextTail);
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ void initMessenger();
|
|||||||
void initsafebox();
|
void initsafebox();
|
||||||
void initguild();
|
void initguild();
|
||||||
void initServerStateChecker();
|
void initServerStateChecker();
|
||||||
|
void initRenderTarget(); // PORT: AVA2 CPythonRenderTargetModule
|
||||||
|
|
||||||
extern const std::string& ApplicationStringTable_GetString(DWORD dwID);
|
extern const std::string& ApplicationStringTable_GetString(DWORD dwID);
|
||||||
extern const std::string& ApplicationStringTable_GetString(DWORD dwID, LPCSTR szKey);
|
extern const std::string& ApplicationStringTable_GetString(DWORD dwID, LPCSTR szKey);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Exercise the real runtime and the SDK's real subnet gateway, without a login UI or OS routes.
|
||||||
|
#include "transport.h"
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <chrono>
|
||||||
|
#include <thread>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#define CHECK(x) do{if(!(x)){std::fprintf(stderr,"line %d: %s errno=%d\n",__LINE__,#x,errno);std::abort();}}while(0)
|
||||||
|
using namespace mt::meshport;
|
||||||
|
sockaddr_in endpoint(const char* host,int port){sockaddr_in result{};result.sin_family=AF_INET;result.sin_port=htons(port);CHECK(inet_pton(AF_INET,host,&result.sin_addr)==1);return result;}
|
||||||
|
int tcp(int port){
|
||||||
|
int fd=::socket(AF_INET,SOCK_STREAM,0);CHECK(fd>=0);fcntl(fd,F_SETFL,O_NONBLOCK);
|
||||||
|
CHECK(connect_socket(fd,endpoint("10.253.79.1",port))==-1);CHECK(pending(fd));
|
||||||
|
auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(70);int state=0;
|
||||||
|
while(!state&&std::chrono::steady_clock::now()<deadline){state=poll_socket(fd);std::this_thread::sleep_for(std::chrono::milliseconds(1));}
|
||||||
|
CHECK(state==1);fcntl(fd,F_SETFL,0);timeval timeout{3,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));return fd;
|
||||||
|
}
|
||||||
|
void finish(int fd){forget_socket(fd);::close(fd);}
|
||||||
|
void echo(int port,const char* text){
|
||||||
|
int fd=tcp(port);size_t size=std::strlen(text);CHECK(::send(fd,text,size,0)==static_cast<ssize_t>(size));
|
||||||
|
char data[128]{};size_t got=0;while(got<size){auto count=::recv(fd,data+got,sizeof(data)-got,0);CHECK(count>0);got+=count;}
|
||||||
|
CHECK(got==size&&std::memcmp(data,text,size)==0);finish(fd);
|
||||||
|
}
|
||||||
|
int main(int argc,char** argv){
|
||||||
|
CHECK(argc==4);std::ifstream input(argv[1]);CHECK(input.good());std::string config((std::istreambuf_iterator<char>(input)),{});
|
||||||
|
// An invalid profile fails without direct fallback; a later request can retry with a valid profile.
|
||||||
|
configure("invalid saved profile");
|
||||||
|
int failed=::socket(AF_INET,SOCK_STREAM,0);CHECK(failed>=0);fcntl(failed,F_SETFL,O_NONBLOCK);
|
||||||
|
CHECK(connect_socket(failed,endpoint("10.253.79.1",std::atoi(argv[2])))==-1);
|
||||||
|
int failed_state=0;auto failed_deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);
|
||||||
|
while(!failed_state&&std::chrono::steady_clock::now()<failed_deadline){failed_state=poll_socket(failed);std::this_thread::sleep_for(std::chrono::milliseconds(1));}
|
||||||
|
CHECK(failed_state==-1);finish(failed);take_event();
|
||||||
|
configure(config);int tcp_port=std::atoi(argv[2]),udp_port=std::atoi(argv[3]);
|
||||||
|
int fd=::socket(AF_INET,SOCK_DGRAM,0);CHECK(fd>=0);fcntl(fd,F_SETFL,O_NONBLOCK);
|
||||||
|
auto local=endpoint("127.0.0.1",udp_port);auto remote=endpoint("10.253.79.1",udp_port);
|
||||||
|
CHECK(send_datagram(fd,"LAN",3,0,local)==3);
|
||||||
|
sockaddr_in source{};char data[128]{};ssize_t count=-1;
|
||||||
|
for(int i=0;i<3000&&count<0;++i){count=recv_datagram(fd,data,sizeof(data),0,&source);std::this_thread::sleep_for(std::chrono::milliseconds(1));}
|
||||||
|
CHECK(count==3&&std::memcmp(data,"LAN",3)==0&&source.sin_addr.s_addr==local.sin_addr.s_addr);
|
||||||
|
CHECK(take_event()==0); // Local traffic has not started the runtime.
|
||||||
|
CHECK(send_datagram(fd,"MESH",4,0,remote)==4); // First remote operation is UDP, not login.
|
||||||
|
auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(70);
|
||||||
|
while(!datagram_ready(fd)&&std::chrono::steady_clock::now()<deadline)std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||||
|
CHECK(datagram_ready(fd));
|
||||||
|
CHECK(recv_datagram(fd,data,2,MSG_PEEK,&source)==2);CHECK(std::memcmp(data,"ME",2)==0);
|
||||||
|
CHECK(source.sin_addr.s_addr==remote.sin_addr.s_addr&&source.sin_port==remote.sin_port);
|
||||||
|
CHECK(recv_datagram(fd,data,sizeof(data),0,&source)==4);CHECK(std::memcmp(data,"MESH",4)==0);
|
||||||
|
CHECK(!datagram_ready(fd));
|
||||||
|
std::thread auth([&]{echo(tcp_port,"account-connection");});
|
||||||
|
std::thread game([&]{echo(tcp_port,"game-map-reconnect-guild-marks");});auth.join();game.join();
|
||||||
|
int http=tcp(tcp_port);const char request[]="POST /register HTTP/1.0\r\nContent-Length: 0\r\n\r\n";
|
||||||
|
CHECK(::send(http,request,sizeof(request)-1,0)>0);count=::recv(http,data,sizeof(data)-1,0);CHECK(count>0);
|
||||||
|
data[count]=0;CHECK(std::strstr(data,"200 OK")&&std::strstr(data,"OK\n"));finish(http);
|
||||||
|
// A new request outside every learned route stays pending; it must not connect directly.
|
||||||
|
int missing=::socket(AF_INET,SOCK_STREAM,0);CHECK(missing>=0);fcntl(missing,F_SETFL,O_NONBLOCK);
|
||||||
|
CHECK(connect_socket(missing,endpoint("10.252.79.1",tcp_port))==-1);CHECK(pending(missing));
|
||||||
|
CHECK(poll_socket(missing)==0);socklen_t len=sizeof(source);CHECK(getpeername(missing,reinterpret_cast<sockaddr*>(&source),&len)<0);
|
||||||
|
finish(missing);finish(fd);shutdown();
|
||||||
|
std::puts("Real SDK: LAN bypass, UDP-first DHCP, UDP source/peek, concurrent TCP, HTTP, reconnect and missing-route cancellation passed");
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Exercise real SDK DHCP against an isolated loopback peer; installs no OS routes."""
|
||||||
|
import ctypes
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
library = Path(sys.argv[1]) if len(sys.argv) > 1 else root / 'build/meshport-runtime/release/libmetin2_meshport.dylib'
|
||||||
|
ffi = ctypes.CDLL(str(library))
|
||||||
|
ffi.meshport_context_new.restype = ctypes.c_uint64
|
||||||
|
ffi.meshport_call.argtypes = [ctypes.c_uint64, ctypes.c_char_p]
|
||||||
|
ffi.meshport_call.restype = ctypes.c_void_p
|
||||||
|
ffi.meshport_string_free.argtypes = [ctypes.c_void_p]
|
||||||
|
ffi.meshport_context_free.argtypes = [ctypes.c_uint64]
|
||||||
|
def call(context, **request):
|
||||||
|
pointer = ffi.meshport_call(context, json.dumps(request).encode())
|
||||||
|
assert pointer, 'missing SDK response'
|
||||||
|
try: response = json.loads(ctypes.string_at(pointer))
|
||||||
|
finally: ffi.meshport_string_free(pointer)
|
||||||
|
assert response['ok'], 'SDK request failed: ' + request['op']
|
||||||
|
return response['data']
|
||||||
|
with socket.socket() as listener:
|
||||||
|
listener.bind(('127.0.0.1', 0))
|
||||||
|
port = listener.getsockname()[1]
|
||||||
|
identity = 'metin2-isolated-test-' + uuid.uuid4().hex
|
||||||
|
base = dict(network_name=identity, network_secret=uuid.uuid4().hex, runtime_mode='application',
|
||||||
|
stun_servers=[], stun_servers_v6=[], listeners=[], enable_ipv6=False)
|
||||||
|
contexts = [ffi.meshport_context_new(), ffi.meshport_context_new()]
|
||||||
|
try:
|
||||||
|
info = call(contexts[0], op='abi_info')
|
||||||
|
assert info['abi'] == 1 and info['revision'] >= 2
|
||||||
|
server = dict(base, hostname='dhcp-seed', ipv4='10.253.85.1/24', dhcp=False,
|
||||||
|
listeners=['tcp://127.0.0.1:%d' % port])
|
||||||
|
client = dict(base, hostname='dhcp-client', ipv4='', dhcp=True,
|
||||||
|
peers=['tcp://127.0.0.1:%d' % port])
|
||||||
|
for context, profile in zip(contexts, (server, client)):
|
||||||
|
config = call(context, op='build_config', profile=profile)['toml']
|
||||||
|
call(context, op='start_bridged', toml=config)
|
||||||
|
deadline = time.monotonic() + 40
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
data = call(contexts[1], op='status')
|
||||||
|
overlay = data.get('platform_network', {}).get('overlay') or {}
|
||||||
|
address = overlay.get('address')
|
||||||
|
if address and address != '0.0.0.0' and data.get('health', {}).get('mesh_routable'):
|
||||||
|
assert address.startswith('10.253.85.') and address != '10.253.85.1'
|
||||||
|
assert data.get('nodes'), 'DHCP node must have joined seed network'
|
||||||
|
print('Real SDK DHCP passed: assigned %s/%s; application bridge healthy' % (address, overlay['prefix']))
|
||||||
|
break
|
||||||
|
time.sleep(.25)
|
||||||
|
else: raise AssertionError('DHCP did not assign an address before deadline')
|
||||||
|
finally:
|
||||||
|
for context in reversed(contexts): ffi.meshport_context_free(context)
|
||||||
|
print('SDK contexts and loopback listeners stopped')
|
||||||
|
|
||||||
|
if __name__ == '__main__': main()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Actual notice script observes native routing; it must never intercept login credentials."""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
SOURCE=(Path(__file__).resolve().parents[2]/'src/platform/ScriptLib/MeshPortScript.inc').read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
|
||||||
|
class Widget:
|
||||||
|
def __init__(self):self.shown=False;self.text=''
|
||||||
|
def Show(self):self.shown=True
|
||||||
|
def Hide(self):self.shown=False
|
||||||
|
def SetText(self,text):self.text=text
|
||||||
|
def __getattr__(self,name):return lambda *args:None
|
||||||
|
class Tests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.now=0;self.events=[]
|
||||||
|
modules={n:types.ModuleType(n) for n in ['app','ui','wndMgr','player','_mt_meshport','introLogin','net']}
|
||||||
|
modules['app'].GetTime=lambda:self.now
|
||||||
|
modules['ui'].Window=modules['ui'].TextLine=Widget
|
||||||
|
modules['wndMgr'].GetScreenWidth=lambda:1280
|
||||||
|
modules['player'].AutoHuntLocalize=lambda value:value
|
||||||
|
self.cancelled=False
|
||||||
|
modules['net'].CancelPendingLogin=lambda:self.cancelled
|
||||||
|
self.login=type('Login',(),{'SetPasswordEditLineFocus':lambda s:None,'OnPressExitKey':lambda s:None,'Close':lambda s:None})
|
||||||
|
modules['introLogin'].LoginWindow=self.login
|
||||||
|
modules['_mt_meshport'].event=lambda:self.events.pop(0) if self.events else 0
|
||||||
|
self.old={n:sys.modules.get(n) for n in modules};sys.modules.update(modules)
|
||||||
|
self.scope={};exec(compile(SOURCE,'MeshPortScript.inc','exec'),self.scope)
|
||||||
|
self.notice=self.scope['notice']
|
||||||
|
def tearDown(self):
|
||||||
|
for name,value in self.old.items():
|
||||||
|
if value is None:sys.modules.pop(name,None)
|
||||||
|
else:sys.modules[name]=value
|
||||||
|
def test_local_requests_do_not_show_a_mesh_notice(self):
|
||||||
|
self.notice.OnUpdate();self.assertFalse(self.notice.label.shown)
|
||||||
|
def test_prepare_is_not_reported_as_success(self):
|
||||||
|
self.events=[1];self.notice.OnUpdate()
|
||||||
|
self.assertIn('等待动态分配',self.notice.label.text)
|
||||||
|
self.assertNotEqual(self.notice.label.text,'组网成功')
|
||||||
|
def test_success_expires_after_six_seconds(self):
|
||||||
|
self.events=[2];self.notice.OnUpdate();self.assertEqual(self.notice.label.text,'组网成功')
|
||||||
|
self.now=7;self.notice.OnUpdate();self.assertFalse(self.notice.label.shown)
|
||||||
|
def test_failure_and_recovery(self):
|
||||||
|
self.events=[3,1,2]
|
||||||
|
self.notice.OnUpdate();self.assertIn('失败',self.notice.label.text)
|
||||||
|
self.notice.OnUpdate();self.notice.OnUpdate();self.assertEqual(self.notice.label.text,'组网成功')
|
||||||
|
def test_no_login_hooks_or_password_capture(self):
|
||||||
|
self.assertNotIn('LoginWindow.Connect =',SOURCE);self.assertNotIn('password',SOURCE)
|
||||||
|
def test_cancel_login_hides_only_its_pending_notice(self):
|
||||||
|
self.events=[1];self.notice.OnUpdate();self.cancelled=True
|
||||||
|
self.login().SetPasswordEditLineFocus()
|
||||||
|
self.assertFalse(self.notice.label.shown)
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Run the native integration executable against an isolated, real SDK gateway."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def check_client(root, temporary, profile, info):
|
||||||
|
result_path=Path(temporary)/'client-registration-result.txt'
|
||||||
|
source=r"""import ui, app, net
|
||||||
|
# Cancelling a pending login must not cancel the independent channel query or registration.
|
||||||
|
net.ConnectTCP('10.252.79.1', %d)
|
||||||
|
assert net.CancelPendingLogin()
|
||||||
|
assert not net.CancelPendingLogin()
|
||||||
|
class RegistrationProbe(ui.Window):
|
||||||
|
def __init__(self):
|
||||||
|
ui.Window.__init__(self)
|
||||||
|
self.phase = 0
|
||||||
|
self.trace = %r
|
||||||
|
open(self.trace, 'w').write('started\n')
|
||||||
|
self.started = app.GetTime()
|
||||||
|
net.RegisterAccount('10.252.79.1', %d, 'isolatedtest', 'unused', '1234567')
|
||||||
|
self.Show()
|
||||||
|
def OnUpdate(self):
|
||||||
|
if self.phase == 0 and app.GetTime() - self.started > .25:
|
||||||
|
net.CancelRegisterAccount()
|
||||||
|
self.phase = 1
|
||||||
|
open(self.trace, 'a').write('cancel requested\n')
|
||||||
|
result = net.GetRegisterAccountResult()
|
||||||
|
if self.phase == 1 and result:
|
||||||
|
open(self.trace, 'a').write('cancel result: ' + result + '\n')
|
||||||
|
assert result == 'ERR CANCELLED', result
|
||||||
|
net.RegisterAccount('10.253.79.1', %d, 'isolatedtest', 'unused', '1234567')
|
||||||
|
self.phase = 2
|
||||||
|
open(self.trace, 'a').write('remote registration started\n')
|
||||||
|
elif self.phase == 2 and result:
|
||||||
|
open(%r, 'w').write(result)
|
||||||
|
self.Hide()
|
||||||
|
_registration_probe = RegistrationProbe()
|
||||||
|
"""%(info['tcp_port'],str(result_path)+'.trace',info['tcp_port'],info['tcp_port'],str(result_path))
|
||||||
|
args=[str(root/'build/release/src/host/mt_native_render'),'--login-screen',
|
||||||
|
'--live-client',str(root/'build/zh-locale/Client'),
|
||||||
|
'--live-server','10.253.79.1:%d:%d'%(info['tcp_port'],info['tcp_port']),
|
||||||
|
'--frames','600','--py-exec',source]
|
||||||
|
with open(root/'build/meshport-registration-runtime.log','w') as output:
|
||||||
|
result=subprocess.run(args,env=dict(os.environ,MT_MESHPORT_CONFIG=str(profile),MT_MESHPORT_TRACE='1'),
|
||||||
|
stdout=output,stderr=subprocess.STDOUT,timeout=45)
|
||||||
|
assert result.returncode==0, 'native client launch failed'
|
||||||
|
if not result_path.exists() or result_path.read_text()!='OK':
|
||||||
|
trace=Path(str(result_path)+'.trace')
|
||||||
|
print('Client registration trace:',trace.read_text() if trace.exists() else 'no updates')
|
||||||
|
print('Client registration result:',result_path.read_text() if result_path.exists() else 'no result')
|
||||||
|
raise AssertionError('original HTTP registration/cancellation path failed')
|
||||||
|
print('Native Python 2.7 client: pre-login channel routing, actual HTTP registration and cancellation passed')
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root=Path(__file__).resolve().parents[2]
|
||||||
|
fixture=root/'build/meshport-runtime/release/examples/gateway_fixture'
|
||||||
|
binary=Path(sys.argv[1]) if len(sys.argv)>1 else root/'build/native/src/meshport/meshport_network_paths_test'
|
||||||
|
with tempfile.TemporaryDirectory(prefix='metin2-mesh-') as temporary:
|
||||||
|
log=open(Path(temporary)/'gateway.log','w+')
|
||||||
|
gateway=subprocess.Popen([str(fixture)],stdout=subprocess.PIPE,stderr=log,text=True)
|
||||||
|
try:
|
||||||
|
import selectors
|
||||||
|
selector=selectors.DefaultSelector();selector.register(gateway.stdout,selectors.EVENT_READ)
|
||||||
|
if not selector.select(20):raise RuntimeError('gateway startup timed out')
|
||||||
|
info=json.loads(gateway.stdout.readline())
|
||||||
|
config=Path(temporary)/'profile.toml'
|
||||||
|
config.write_text("dhcp=true\nlisteners=[]\nstun_servers=[]\nstun_servers_v6=[]\n[[peer]]\nuri='tcp://127.0.0.1:%d'\n[network_identity]\nnetwork_name='%s'\nnetwork_secret='isolated-test-only'\n[flags]\nno_tun=true\nuse_smoltcp=true\nenable_encryption=true\n"%(info['mesh_port'],info['network_name']))
|
||||||
|
result=subprocess.run([str(binary),str(config),str(info['tcp_port']),str(info['udp_port'])],timeout=100,env=dict(os.environ,MT_MESHPORT_TRACE="1"))
|
||||||
|
if result.returncode:raise RuntimeError('native network paths failed (%s)'%result.returncode)
|
||||||
|
if '--client' in sys.argv:check_client(root,temporary,config,info)
|
||||||
|
finally:
|
||||||
|
gateway.terminate()
|
||||||
|
try:gateway.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:gateway.kill();gateway.wait()
|
||||||
|
log.close()
|
||||||
|
if __name__=='__main__':main()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#include "transport.h"
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <chrono>
|
||||||
|
#include <thread>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "line %d: %s\n", __LINE__, #x); std::abort(); } } while (0)
|
||||||
|
using namespace mt::meshport;
|
||||||
|
uint32_t ip(const char* text) { in_addr addr{}; CHECK(inet_pton(AF_INET,text,&addr)==1); return addr.s_addr; }
|
||||||
|
void transfer(int fd, void* p, size_t size, bool send) {
|
||||||
|
auto* bytes = static_cast<unsigned char*>(p);
|
||||||
|
for (size_t offset=0; offset<size;) {
|
||||||
|
auto count = send ? ::send(fd,bytes+offset,1,0) : ::recv(fd,bytes+offset,size-offset,0);
|
||||||
|
CHECK(count>0); offset += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void socks(bool reject) {
|
||||||
|
int listener=::socket(AF_INET,SOCK_STREAM,0); CHECK(listener>=0);
|
||||||
|
sockaddr_in local{};local.sin_family=AF_INET;local.sin_addr.s_addr=htonl(INADDR_LOOPBACK);
|
||||||
|
CHECK(::bind(listener,reinterpret_cast<sockaddr*>(&local),sizeof(local))==0);
|
||||||
|
CHECK(::listen(listener,1)==0);socklen_t len=sizeof(local);
|
||||||
|
CHECK(getsockname(listener,reinterpret_cast<sockaddr*>(&local),&len)==0);
|
||||||
|
std::thread peer([&] {
|
||||||
|
int fd=::accept(listener,nullptr,nullptr);CHECK(fd>=0);
|
||||||
|
timeval timeout{3,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));
|
||||||
|
unsigned char greeting[3];transfer(fd,greeting,3,false);CHECK(greeting[0]==5 && greeting[1]==1 && greeting[2]==0);
|
||||||
|
unsigned char auth[2]={5,0};transfer(fd,auth,2,true);
|
||||||
|
unsigned char request[10];transfer(fd,request,10,false);
|
||||||
|
CHECK(request[0]==5 && request[1]==1 && request[3]==1);
|
||||||
|
CHECK(std::memcmp(request+4,"\x0a\xfd\x55\x02",4)==0);
|
||||||
|
CHECK(request[8]==0x32 && request[9]==0xc8); // port 13000
|
||||||
|
unsigned char response[10]={5,static_cast<unsigned char>(reject?5:0),0,1,0,0,0,0,0,0};
|
||||||
|
transfer(fd,response,10,true);
|
||||||
|
if (!reject) { char payload[4]={'g','a','m','e'};transfer(fd,payload,4,true); }
|
||||||
|
::close(fd);
|
||||||
|
});
|
||||||
|
set_routes("127.0.0.1:"+std::to_string(ntohs(local.sin_port)),{"10.253.85.0/24"});
|
||||||
|
int fd=::socket(AF_INET,SOCK_STREAM,0);CHECK(fd>=0);fcntl(fd,F_SETFL,O_NONBLOCK);
|
||||||
|
sockaddr_in target{};target.sin_family=AF_INET;target.sin_addr.s_addr=ip("10.253.85.2");target.sin_port=htons(13000);
|
||||||
|
int owner;CHECK(connect_socket(fd,target,&owner)==-1);CHECK(pending(fd));CHECK(pending_owner(&owner));
|
||||||
|
int result=0;
|
||||||
|
const auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(3);
|
||||||
|
while (!result && std::chrono::steady_clock::now()<deadline) {result=poll_socket(fd);std::this_thread::sleep_for(std::chrono::milliseconds(1));}
|
||||||
|
CHECK(result==(reject?-1:1));
|
||||||
|
if (!reject) { fcntl(fd,F_SETFL,0); char payload[4];transfer(fd,payload,4,false);CHECK(std::memcmp(payload,"game",4)==0); }
|
||||||
|
forget_socket(fd);CHECK(!pending(fd));CHECK(!pending_owner(&owner));::close(fd);peer.join();::close(listener);set_routes("",{});
|
||||||
|
}
|
||||||
|
void udp_socks() {
|
||||||
|
int listener=::socket(AF_INET,SOCK_STREAM,0);CHECK(listener>=0);
|
||||||
|
auto local=sockaddr_in{};local.sin_family=AF_INET;local.sin_addr.s_addr=htonl(INADDR_LOOPBACK);
|
||||||
|
CHECK(::bind(listener,reinterpret_cast<sockaddr*>(&local),sizeof(local))==0);CHECK(::listen(listener,1)==0);
|
||||||
|
socklen_t len=sizeof(local);CHECK(getsockname(listener,reinterpret_cast<sockaddr*>(&local),&len)==0);
|
||||||
|
int relay=::socket(AF_INET,SOCK_DGRAM,0);CHECK(relay>=0);auto relay_address=local;relay_address.sin_port=0;
|
||||||
|
CHECK(::bind(relay,reinterpret_cast<sockaddr*>(&relay_address),sizeof(relay_address))==0);
|
||||||
|
len=sizeof(relay_address);CHECK(getsockname(relay,reinterpret_cast<sockaddr*>(&relay_address),&len)==0);
|
||||||
|
std::thread peer([&]{
|
||||||
|
int control=::accept(listener,nullptr,nullptr);CHECK(control>=0);timeval timeout{4,0};
|
||||||
|
setsockopt(control,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(relay,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));
|
||||||
|
unsigned char greeting[3];transfer(control,greeting,3,false);unsigned char auth[2]={5,0};transfer(control,auth,2,true);
|
||||||
|
unsigned char request[10];transfer(control,request,10,false);CHECK(request[1]==3);
|
||||||
|
unsigned char response[10]={5,0,0,1,127,0,0,1,0,0};std::memcpy(response+8,&relay_address.sin_port,2);transfer(control,response,10,true);
|
||||||
|
unsigned char frame[128];sockaddr_in source{};socklen_t size=sizeof(source);
|
||||||
|
auto count=::recvfrom(relay,frame,sizeof(frame),0,reinterpret_cast<sockaddr*>(&source),&size);
|
||||||
|
CHECK(count==14);CHECK(std::memcmp(frame+10,"ping",4)==0);
|
||||||
|
CHECK(::sendto(relay,frame,count,0,reinterpret_cast<sockaddr*>(&source),size)==count);
|
||||||
|
char close_byte;::recv(control,&close_byte,1,0);::close(control);
|
||||||
|
});
|
||||||
|
set_routes("127.0.0.1:"+std::to_string(ntohs(local.sin_port)),{"10.253.85.0/24"});
|
||||||
|
int fd=::socket(AF_INET,SOCK_DGRAM,0);CHECK(fd>=0);fcntl(fd,F_SETFL,O_NONBLOCK);
|
||||||
|
auto target=sockaddr_in{};target.sin_family=AF_INET;target.sin_addr.s_addr=ip("10.253.85.2");target.sin_port=htons(13000);
|
||||||
|
CHECK(send_datagram(fd,"ping",4,0,target)==4);
|
||||||
|
const auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(4);
|
||||||
|
while(!datagram_ready(fd)&&std::chrono::steady_clock::now()<deadline)std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||||
|
CHECK(datagram_ready(fd));sockaddr_in source{};char payload[4];
|
||||||
|
CHECK(recv_datagram(fd,payload,2,MSG_PEEK,&source)==2);CHECK(source.sin_addr.s_addr==target.sin_addr.s_addr&&source.sin_port==target.sin_port);
|
||||||
|
CHECK(recv_datagram(fd,payload,4,0,&source)==4);CHECK(std::memcmp(payload,"ping",4)==0);
|
||||||
|
forget_socket(fd);::close(fd);peer.join();::close(listener);::close(relay);set_routes("",{});
|
||||||
|
}
|
||||||
|
int main() {
|
||||||
|
CHECK(in_subnet(ip("192.168.21.203"),ip("192.168.21.5"),ip("255.255.255.0")));
|
||||||
|
CHECK(!in_subnet(ip("192.168.20.203"),ip("192.168.21.5"),ip("255.255.255.0")));
|
||||||
|
CHECK(in_subnet(ip("192.168.20.203"),ip("192.168.21.5"),ip("255.255.254.0")));
|
||||||
|
CHECK(!in_subnet(ip("192.168.21.203"),ip("192.168.21.5"),0));
|
||||||
|
CHECK(same_subnet("127.0.0.1"));CHECK(!same_subnet("invalid"));
|
||||||
|
CHECK(contains("10.253.85.0/24","10.253.85.2"));CHECK(!contains("10.253.85.0/24","10.253.86.2"));
|
||||||
|
CHECK(contains("10.253.85.2/32","10.253.85.2"));CHECK(!contains("10.0.0.0/0","10.1.2.3"));
|
||||||
|
CHECK(!contains("10.0.0.0/99999999","10.1.2.3"));
|
||||||
|
socks(false);socks(true);udp_socks();
|
||||||
|
configure("invalid saved profile");
|
||||||
|
int failed=::socket(AF_INET,SOCK_STREAM,0);CHECK(failed>=0);fcntl(failed,F_SETFL,O_NONBLOCK);
|
||||||
|
sockaddr_in absent{};absent.sin_family=AF_INET;absent.sin_addr.s_addr=ip("10.253.85.2");absent.sin_port=htons(13000);
|
||||||
|
CHECK(connect_socket(failed,absent)==-1);
|
||||||
|
int state=0;auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);
|
||||||
|
while(!state&&std::chrono::steady_clock::now()<deadline){state=poll_socket(failed);std::this_thread::sleep_for(std::chrono::milliseconds(1));}
|
||||||
|
CHECK(state==-1);socklen_t length=sizeof(absent);CHECK(getpeername(failed,reinterpret_cast<sockaddr*>(&absent),&length)<0);
|
||||||
|
forget_socket(failed);::close(failed);
|
||||||
|
shutdown();std::puts("MeshPort subnet, TCP/UDP SOCKS, peek/source preservation and failure without direct fallback passed");
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// PORT: adds or replaces whole item_proto records (all 156-byte fields); untouched records stay byte-identical.
|
||||||
|
// Row: vnum type sub size anti flags wear immune buy sell refined refine_set magic_pct
|
||||||
|
// limit0 value0 limit1 value1 apply0 value0 apply1 value1 apply2 value2 v0..v5 specular gain_socket \t name
|
||||||
|
#include "GameLib/StdAfx.h"
|
||||||
|
#include "GameLib/ItemData.h"
|
||||||
|
#include "EterBase/lzo.h"
|
||||||
|
#include <array>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#include <map>
|
||||||
|
#include <sstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <vector>
|
||||||
|
using Item=CItemData::TItemTable;
|
||||||
|
static constexpr std::array<DWORD,4> item_key{{173217,72619434,408587239,27973291}};
|
||||||
|
static std::vector<BYTE> read_file(const char* path){
|
||||||
|
std::ifstream stream(path,std::ios::binary);
|
||||||
|
if(!stream)throw std::runtime_error("cannot read proto");
|
||||||
|
return {std::istreambuf_iterator<char>(stream),std::istreambuf_iterator<char>()};
|
||||||
|
}
|
||||||
|
static DWORD word(const std::vector<BYTE>& data,size_t offset){
|
||||||
|
if(offset+4>data.size())throw std::runtime_error("truncated proto");
|
||||||
|
DWORD value;std::memcpy(&value,data.data()+offset,4);return value;
|
||||||
|
}
|
||||||
|
static void put_word(std::vector<BYTE>& data,size_t offset,DWORD value){std::memcpy(data.data()+offset,&value,4);}
|
||||||
|
static void dump(const Item& r){
|
||||||
|
std::printf("%u %u %u %u %u %u %u %u %u %u %u %u %u",r.dwVnum,r.bType,r.bSubType,r.bSize,r.dwAntiFlags,r.dwFlags,r.dwWearFlags,r.dwImmuneFlag,
|
||||||
|
r.dwIBuyItemPrice,r.dwISellItemPrice,r.dwRefinedVnum,r.wRefineSet,r.bAlterToMagicItemPct);
|
||||||
|
for(const auto& limit:r.aLimits)std::printf(" %u %d",limit.bType,int(limit.lValue));
|
||||||
|
for(const auto& apply:r.aApplies)std::printf(" %u %d",apply.bType,int(apply.lValue));
|
||||||
|
for(auto value:r.alValues)std::printf(" %d",int(value));
|
||||||
|
std::printf(" %u %u\t%.*s\n",r.bSpecular,r.bGainSocketPct,int(strnlen(r.szLocaleName,sizeof(r.szLocaleName))),r.szLocaleName);
|
||||||
|
}
|
||||||
|
int main(int argc,char** argv) {
|
||||||
|
const bool dumping=argc==3&&!std::strcmp(argv[1],"--dump");
|
||||||
|
if(argc!=4&&!dumping){std::fprintf(stderr,"usage: item_proto_patch in_proto rows.tsv out_proto | --dump proto\n");return 2;}
|
||||||
|
if(dumping)argv[1]=argv[2];
|
||||||
|
try {
|
||||||
|
static CLZO lzo; auto key=item_key;
|
||||||
|
auto input=read_file(argv[1]);
|
||||||
|
if(input.size()<20 || std::memcmp(input.data(),"MIPX",4) || word(input,4)!=1 || word(input,8)!=sizeof(Item))throw std::runtime_error("item proto stride");
|
||||||
|
CLZObject decoded;
|
||||||
|
if(!CLZO::Instance().Decompress(decoded,input.data()+20,key.data()))throw std::runtime_error("item proto decode");
|
||||||
|
if(decoded.GetSize()!=size_t(word(input,12))*sizeof(Item))throw std::runtime_error("proto count/size mismatch");
|
||||||
|
std::map<DWORD,Item> rows;
|
||||||
|
for(DWORD i=0;i<word(input,12);++i){Item r;std::memcpy(&r,decoded.GetBuffer()+i*sizeof(Item),sizeof(Item));rows[r.dwVnum]=r;}
|
||||||
|
if(dumping){for(const auto& pair:rows)dump(pair.second);return 0;}
|
||||||
|
std::ifstream file(argv[2],std::ios::binary);std::string line;size_t added=0,replaced=0;
|
||||||
|
while(std::getline(file,line)) {
|
||||||
|
if(line.empty())continue;
|
||||||
|
auto tab=line.find('\t',0);std::string numbers=line,name;
|
||||||
|
// the name is the last tab-separated column
|
||||||
|
auto last=line.rfind('\t');if(last==std::string::npos)throw std::runtime_error("item row without name");
|
||||||
|
numbers=line.substr(0,last);name=line.substr(last+1);(void)tab;
|
||||||
|
std::istringstream stream(numbers);std::array<long long,31> f{};
|
||||||
|
for(auto& value:f)if(!(stream>>value))throw std::runtime_error("item row: "+line.substr(0,40));
|
||||||
|
long long extra;if(stream>>extra)throw std::runtime_error("item row has extra columns");
|
||||||
|
if(name.size()>CItemData::ITEM_NAME_MAX_LEN)throw std::runtime_error("item name too long");
|
||||||
|
Item r{};int i=0;
|
||||||
|
r.dwVnum=f[i++];r.bType=f[i++];r.bSubType=f[i++];r.bSize=f[i++];r.dwAntiFlags=f[i++];r.dwFlags=f[i++];
|
||||||
|
r.dwWearFlags=f[i++];r.dwImmuneFlag=f[i++];r.dwIBuyItemPrice=f[i++];r.dwISellItemPrice=f[i++];
|
||||||
|
r.dwRefinedVnum=f[i++];r.wRefineSet=f[i++];r.bAlterToMagicItemPct=f[i++];
|
||||||
|
for(auto& limit:r.aLimits){limit.bType=f[i++];limit.lValue=f[i++];}
|
||||||
|
for(auto& apply:r.aApplies){apply.bType=f[i++];apply.lValue=f[i++];}
|
||||||
|
for(auto& value:r.alValues)value=f[i++];
|
||||||
|
r.bSpecular=f[i++];r.bGainSocketPct=f[i++];
|
||||||
|
std::memcpy(r.szName,name.data(),name.size());std::memcpy(r.szLocaleName,name.data(),name.size());
|
||||||
|
(rows.count(r.dwVnum)?replaced:added)++;rows[r.dwVnum]=r;
|
||||||
|
}
|
||||||
|
std::vector<BYTE> plain;for(const auto& pair:rows){const BYTE* bytes=reinterpret_cast<const BYTE*>(&pair.second);plain.insert(plain.end(),bytes,bytes+sizeof(Item));}
|
||||||
|
CLZObject encoded;if(!CLZO::Instance().CompressEncryptedMemory(encoded,plain.data(),plain.size(),key.data()))throw std::runtime_error("encode");
|
||||||
|
std::vector<BYTE> result(input.begin(),input.begin()+20);put_word(result,12,rows.size());put_word(result,16,encoded.GetSize());
|
||||||
|
result.insert(result.end(),encoded.GetBuffer(),encoded.GetBuffer()+encoded.GetSize());
|
||||||
|
CLZObject verified;if(!CLZO::Instance().Decompress(verified,result.data()+20,key.data()) || verified.GetSize()!=plain.size() || std::memcmp(verified.GetBuffer(),plain.data(),plain.size()))throw std::runtime_error("round-trip");
|
||||||
|
std::ofstream out(argv[3],std::ios::binary|std::ios::trunc);
|
||||||
|
if(!out.write(reinterpret_cast<const char*>(result.data()),result.size()))throw std::runtime_error("cannot write proto");
|
||||||
|
std::printf("items added=%zu replaced=%zu total=%zu; verified round-trip\n",added,replaced,rows.size());return 0;
|
||||||
|
}catch(const std::exception& e){std::fprintf(stderr,"%s\n",e.what());return 1;}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
// Runtime loading remains the original 40250 path and binary format.
|
// Runtime loading remains the original 40250 path and binary format.
|
||||||
// An optional item limits table (vnum, limit type 0, value 0, limit type 1, value 1) mirrors the few
|
// An optional item limits table (vnum, limit type 0, value 0, limit type 1, value 1) mirrors the few
|
||||||
// item_proto.txt limit changes made on our server (tools/server/*), so the tooltip states what the
|
// item_proto.txt limit changes made on our server (tools/server/*), so the tooltip states what the
|
||||||
// server enforces. An optional 14-row stone overlay restores extracted +6 stone data.
|
// server enforces. An optional 14-row stone overlay restores extracted +6 stone data. An optional shape table
|
||||||
|
// (vnum, size, flags, anti flags) mirrors items the server gives a new use (tools/server/vip-card), so they
|
||||||
|
// take a cell, stack and refuse NPC sale as the server does.
|
||||||
#include "GameLib/StdAfx.h"
|
#include "GameLib/StdAfx.h"
|
||||||
#include "GameLib/ItemData.h"
|
#include "GameLib/ItemData.h"
|
||||||
#include "UserInterface/StdAfx.h"
|
#include "UserInterface/StdAfx.h"
|
||||||
@@ -25,6 +27,7 @@ namespace {
|
|||||||
using Item = CItemData::TItemTable;
|
using Item = CItemData::TItemTable;
|
||||||
using Mob = CPythonNonPlayer::TMobTable;
|
using Mob = CPythonNonPlayer::TMobTable;
|
||||||
std::map<DWORD, std::array<long, 11>> stones;
|
std::map<DWORD, std::array<long, 11>> stones;
|
||||||
|
std::map<DWORD, std::array<DWORD, 3>> shapes;
|
||||||
|
|
||||||
constexpr std::array<DWORD, 4> item_key{{173217, 72619434, 408587239, 27973291}};
|
constexpr std::array<DWORD, 4> item_key{{173217, 72619434, 408587239, 27973291}};
|
||||||
constexpr std::array<DWORD, 4> mob_key{{4813894, 18955, 552631, 6822045}};
|
constexpr std::array<DWORD, 4> mob_key{{4813894, 18955, 552631, 6822045}};
|
||||||
@@ -109,6 +112,12 @@ void apply_limits<Item>(Item& record, const std::map<DWORD, Limits>& limits, std
|
|||||||
record.aApplies[0].bType = s[3]; record.aApplies[0].lValue = s[4];
|
record.aApplies[0].bType = s[3]; record.aApplies[0].lValue = s[4];
|
||||||
for (int i = 0; i < 6; ++i) record.alValues[i] = s[5+i];
|
for (int i = 0; i < 6; ++i) record.alValues[i] = s[5+i];
|
||||||
}
|
}
|
||||||
|
const auto shape = shapes.find(record.dwVnum);
|
||||||
|
if (shape != shapes.end()) {
|
||||||
|
record.bSize = static_cast<BYTE>(shape->second[0]);
|
||||||
|
record.dwFlags = shape->second[1];
|
||||||
|
record.dwAntiFlags = shape->second[2];
|
||||||
|
}
|
||||||
const auto found = limits.find(record.dwVnum);
|
const auto found = limits.find(record.dwVnum);
|
||||||
if (found == limits.end()) return;
|
if (found == limits.end()) return;
|
||||||
std::memcpy(record.aLimits, found->second.data(), sizeof(record.aLimits));
|
std::memcpy(record.aLimits, found->second.data(), sizeof(record.aLimits));
|
||||||
@@ -120,6 +129,8 @@ bool limits_field(std::size_t, DWORD, const std::map<DWORD, Limits>&) { return f
|
|||||||
|
|
||||||
template <>
|
template <>
|
||||||
bool limits_field<Item>(std::size_t offset, DWORD vnum, const std::map<DWORD, Limits>& limits) {
|
bool limits_field<Item>(std::size_t offset, DWORD vnum, const std::map<DWORD, Limits>& limits) {
|
||||||
|
if (shapes.count(vnum) && (offset == offsetof(Item, bSize) ||
|
||||||
|
(offset >= offsetof(Item, dwAntiFlags) && offset < offsetof(Item, dwFlags)+sizeof(Item::dwFlags)))) return true;
|
||||||
if (stones.count(vnum) && (offset == offsetof(Item, bType) || offset == offsetof(Item, bSubType) ||
|
if (stones.count(vnum) && (offset == offsetof(Item, bType) || offset == offsetof(Item, bSubType) ||
|
||||||
(offset >= offsetof(Item, dwWearFlags) && offset < offsetof(Item, dwWearFlags)+sizeof(Item::dwWearFlags)) ||
|
(offset >= offsetof(Item, dwWearFlags) && offset < offsetof(Item, dwWearFlags)+sizeof(Item::dwWearFlags)) ||
|
||||||
(offset >= offsetof(Item, aApplies) && offset < offsetof(Item, aApplies)+sizeof(Item::aApplies)) ||
|
(offset >= offsetof(Item, aApplies) && offset < offsetof(Item, aApplies)+sizeof(Item::aApplies)) ||
|
||||||
@@ -199,14 +210,25 @@ std::vector<BYTE> patch(const char* source, const char* names_path,
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
if (argc != 7 && argc != 8 && argc != 9) {
|
if (argc < 7 || argc > 10) {
|
||||||
std::cerr << "usage: locale_proto_patch item_proto mob_proto item_names_gbk mob_names_gbk out_item out_mob"
|
std::cerr << "usage: locale_proto_patch item_proto mob_proto item_names_gbk mob_names_gbk out_item out_mob"
|
||||||
" [item_limits [item_stones]]\n";
|
" [item_limits [item_stones [item_shapes]]]\n";
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
static CLZO lzo;
|
static CLZO lzo;
|
||||||
if (argc == 9) {
|
if (argc == 10) {
|
||||||
|
std::ifstream stream(argv[9]);
|
||||||
|
if (!stream) throw std::runtime_error("cannot read item shapes");
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(stream, line)) {
|
||||||
|
if (line.empty() || line[0] == '#') continue;
|
||||||
|
std::istringstream row(line); DWORD vnum; std::array<DWORD,3> values{};
|
||||||
|
if (!(row >> vnum >> values[0] >> values[1] >> values[2]) || values[0] < 1 || values[0] > 3 ||
|
||||||
|
!shapes.emplace(vnum, values).second) throw std::runtime_error("bad item shape row: " + line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (argc >= 9) {
|
||||||
std::ifstream stream(argv[8]);
|
std::ifstream stream(argv[8]);
|
||||||
if (!stream) throw std::runtime_error("cannot read stone overlay");
|
if (!stream) throw std::runtime_error("cannot read stone overlay");
|
||||||
std::string line;
|
std::string line;
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
def script(name):return (ROOT/'src/platform/ScriptLib'/name).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
class Widget(object):
|
||||||
|
def __init__(self,*a,**k):
|
||||||
|
self.Hide()
|
||||||
|
self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0);self.enabled=True
|
||||||
|
def __getattr__(self,name):return lambda *a,**k:None
|
||||||
|
def Show(self):self.shown=True
|
||||||
|
def Hide(self):self.shown=False
|
||||||
|
def IsShow(self):return self.shown
|
||||||
|
def SetText(self,text):self.text=text
|
||||||
|
def SetPosition(self,x,y):self.pos=(x,y)
|
||||||
|
def GetGlobalPosition(self):return self.pos
|
||||||
|
def SetSize(self,w,h):self.size=(w,h)
|
||||||
|
def GetWidth(self):return self.size[0]
|
||||||
|
def GetHeight(self):return self.size[1]
|
||||||
|
|
||||||
|
|
||||||
|
class Action(Widget):
|
||||||
|
def __init__(self,parent,x,y,width,text,event):
|
||||||
|
Widget.__init__(self);self.pos=(x,y);self.event=event;self.text=text;self.shown=True
|
||||||
|
def SetEnabled(self,enabled):self.enabled=enabled
|
||||||
|
|
||||||
|
|
||||||
|
NAMES={70043:'夺宝手套',71153:'经验药水',27992:'白珍珠',27987:'珍珠蚌'}
|
||||||
|
INFO=['MTBP1 season 202610 1790805600 1793487600 1790900000 6',
|
||||||
|
'MTBP1 mission 1 1 0 20000 150 0 70043 1 71153 5',
|
||||||
|
'MTBP1 mission 2 2 0 30 30 0 25041 2',
|
||||||
|
'MTBP1 mission 3 3 0 300 300 1 50513 3',
|
||||||
|
'MTBP1 mission 4 4 0 172800 5400 0 71094 2',
|
||||||
|
'MTBP1 mission 5 5 27987 100 3 0 27987 5',
|
||||||
|
'MTBP1 mission 9 9 27992 10 0 0',
|
||||||
|
'MTBP1 final 0 0 27992 2 27993 1',
|
||||||
|
'MTBP1 end']
|
||||||
|
|
||||||
|
|
||||||
|
class BattlePassTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.chat=[];self.sent=[];self.clock=100.
|
||||||
|
names=('app','chat','game','grp','item','net','nonplayer','ui','player','mt_biolog')
|
||||||
|
mods={n:types.ModuleType(n) for n in names}
|
||||||
|
app=mods['app'];app.GetTime=lambda:self.clock;app.DIK_F6=64
|
||||||
|
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
|
||||||
|
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,BINARY_ServerCommand_Run=lambda s,l:'old'))
|
||||||
|
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=grp.RenderLine=lambda *a:None
|
||||||
|
self.selected=0
|
||||||
|
def select(v):self.selected=v
|
||||||
|
mods['item'].SelectItem=select;mods['item'].GetItemName=lambda:NAMES.get(self.selected,'')
|
||||||
|
mods['player'].GetItemIndex=lambda cell:25159 if cell==3 else 0
|
||||||
|
mods['player'].INVENTORY_PAGE_SIZE=45;mods['player'].INVENTORY_PAGE_COUNT=5
|
||||||
|
mods['item'].GetValue=lambda i:10
|
||||||
|
mods['net'].SendChatPacket=self.sent.append
|
||||||
|
mods['nonplayer'].GetMonsterName=lambda v:{691:'狼王'}.get(v,'')
|
||||||
|
mods['ui'].Window=Widget
|
||||||
|
mods['mt_biolog'].Action=Action;mods['mt_biolog'].L=lambda t:t
|
||||||
|
mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:self.text(t)
|
||||||
|
self.previous={n:sys.modules.get(n) for n in mods}
|
||||||
|
sys.modules.update(mods)
|
||||||
|
self.ns={'__name__':'mt_battlepass'};exec(script('BattlePassScript.inc'),self.ns)
|
||||||
|
self.game=mods['game'];self.gw=self.game.GameWindow()
|
||||||
|
self.gw.onPressKeyDict={};self.gw.interface=types.SimpleNamespace(wndMiniMap=Widget())
|
||||||
|
self.game.GameWindow.Open(self.gw)
|
||||||
|
self.w=self.ns['g_window'];self.p=self.ns['g_pass']
|
||||||
|
|
||||||
|
def text(self,t):
|
||||||
|
w=Widget();w.text=t;w.shown=True;return w
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for name,value in self.previous.items():
|
||||||
|
if value is None:sys.modules.pop(name,None)
|
||||||
|
else:sys.modules[name]=value
|
||||||
|
|
||||||
|
def feed(self,*lines):
|
||||||
|
for line in lines:self.assertEqual(self.game.GameWindow.BINARY_ServerCommand_Run(self.gw,line),1)
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
self.gw.onPressKeyDict[64]();self.assertTrue(self.w.IsShow());self.assertEqual(self.sent[-1],'/mt_bp 1 info')
|
||||||
|
self.feed(*INFO)
|
||||||
|
|
||||||
|
def test_other_commands_pass_through(self):
|
||||||
|
self.assertEqual(self.game.GameWindow.BINARY_ServerCommand_Run(self.gw,'MTVIP1 times 0 0 0 0 0 0 0'),'old')
|
||||||
|
self.assertIn(64,self.gw.onPressKeyDict)
|
||||||
|
|
||||||
|
def test_info_and_rows(self):
|
||||||
|
self.open();rows=self.w.rows
|
||||||
|
self.assertEqual(sorted(self.p.missions),[1,2,3,4,5,9]);self.assertEqual(self.w.Pages(),2)
|
||||||
|
self.assertEqual(rows[0]['name'].text,'击杀怪物');self.assertEqual(rows[0]['value'].text,'150 / 20,000')
|
||||||
|
self.assertEqual(rows[0]['reward'].text,'奖励:夺宝手套 x1,经验药水 x5')
|
||||||
|
self.assertFalse(rows[0]['button'].enabled);self.assertEqual(rows[0]['button'].text,'未完成')
|
||||||
|
self.assertTrue(rows[1]['button'].enabled);self.assertEqual(rows[1]['button'].text,'领取')
|
||||||
|
self.assertEqual(rows[2]['button'].text,'已领取');self.assertFalse(rows[2]['button'].enabled)
|
||||||
|
self.assertEqual(rows[3]['value'].text,'1.5 / 48 小时')
|
||||||
|
self.assertEqual(rows[4]['name'].text,'钓到 珍珠蚌')
|
||||||
|
self.assertIn('2026 年 10 月',self.w.season.text);self.assertIn('剩余 29 天 22 小时',self.w.season.text)
|
||||||
|
self.w.next.event();self.assertEqual(self.w.page,1)
|
||||||
|
self.assertEqual(rows[0]['name'].text,'向 NPC 出售 白珍珠');self.assertEqual(rows[0]['reward'].text,'无奖励')
|
||||||
|
self.assertFalse(rows[0]['button'].enabled);self.assertFalse(rows[1]['name'].shown)
|
||||||
|
self.assertFalse(self.w.finalButton.enabled);self.assertEqual(self.w.finalState.text,'完成全部任务后可领取')
|
||||||
|
|
||||||
|
def test_progress_done_and_claim(self):
|
||||||
|
self.open()
|
||||||
|
self.feed('MTBP1 progress 1 151');self.assertEqual(self.w.rows[0]['value'].text,'151 / 20,000')
|
||||||
|
self.feed('MTBP1 done 1');self.assertEqual(self.chat[-1],'战令任务完成:击杀怪物,按 F6 领取奖励')
|
||||||
|
self.assertTrue(self.w.rows[0]['button'].enabled)
|
||||||
|
self.clock+=1;self.w.rows[0]['button'].event();self.assertEqual(self.sent[-1],'/mt_bp 1 claim 1')
|
||||||
|
self.assertEqual(self.w.rows[0]['button'].text,'领取中');self.assertFalse(self.w.rows[1]['button'].enabled)
|
||||||
|
self.w.rows[1]['button'].event();self.assertEqual(self.sent[-1],'/mt_bp 1 claim 1') # one claim at a time
|
||||||
|
self.feed('MTBP1 claimed 1');self.assertEqual(self.chat[-1],'战令奖励已放入背包')
|
||||||
|
self.assertEqual(self.w.rows[0]['button'].text,'已领取')
|
||||||
|
self.feed(*INFO) # the server sends the full state after a claim
|
||||||
|
self.clock+=1;self.w.rows[1]['button'].event();self.feed('MTBP1 error space')
|
||||||
|
self.assertEqual(self.chat[-1],'背包空间不足,请整理后再领取');self.assertIn('背包空间不足',self.w.note.text)
|
||||||
|
self.assertTrue(self.w.rows[1]['button'].enabled)
|
||||||
|
|
||||||
|
def test_final(self):
|
||||||
|
self.open()
|
||||||
|
for slot in (1,4,5,9):self.feed('MTBP1 done %d'%slot)
|
||||||
|
self.assertTrue(self.p.AllDone());self.w.Refresh()
|
||||||
|
self.assertTrue(self.w.finalButton.enabled);self.assertEqual(self.w.finalState.text,'全部任务完成')
|
||||||
|
self.clock+=1;self.w.finalButton.event();self.assertEqual(self.sent[-1],'/mt_bp 1 claim 0')
|
||||||
|
self.feed('MTBP1 claimed 0');self.assertEqual(self.chat[-1],'战令最终奖励已放入背包')
|
||||||
|
self.assertEqual(self.w.finalButton.text,'已领取');self.assertFalse(self.w.finalButton.enabled)
|
||||||
|
|
||||||
|
def test_close_none_and_timeout(self):
|
||||||
|
self.open();self.w.OnPressEscapeKey();self.assertEqual(self.sent[-1],'/mt_bp 1 close');self.assertFalse(self.w.IsShow())
|
||||||
|
self.feed('MTBP1 done 2');self.assertEqual(self.chat[-1],'战令任务完成:击杀 Boss,按 F6 领取奖励')
|
||||||
|
self.clock+=1;self.w.Open();self.feed('MTBP1 none')
|
||||||
|
self.assertIsNone(self.p.season);self.assertEqual(self.w.note.text,'当前没有进行中的战令赛季')
|
||||||
|
self.assertFalse(self.w.rows[0]['name'].shown);self.assertFalse(self.w.finalButton.shown)
|
||||||
|
self.w.Hide();self.assertEqual(self.sent[-1],'/mt_bp 1 info') # no season, nothing to close
|
||||||
|
self.clock+=1;self.w.Open();self.clock+=3.5;self.w.OnUpdate()
|
||||||
|
self.assertTrue(self.p.unsupported);self.assertEqual(self.w.note.text,'!服务器尚未启用战令')
|
||||||
|
|
||||||
|
def test_bad_lines_are_dropped(self):
|
||||||
|
self.feed('MTBP1 season 1 2 3 4 0','MTBP1 end');self.assertIsNone(self.p.season)
|
||||||
|
self.feed(INFO[0],INFO[1],'MTBP1 end');self.assertIsNone(self.p.season) # count mismatch
|
||||||
|
self.feed(INFO[0],'MTBP1 mission 1 1 0 x 0 0',*INFO[2:]);self.assertIsNone(self.p.season)
|
||||||
|
self.feed('MTBP1 mission 1 1 0 5 0 0','MTBP1 end');self.assertIsNone(self.p.season) # no season line
|
||||||
|
self.feed('MTBP1 progress 99 1','MTBP1 error nope','MTBP1 bogus')
|
||||||
|
self.feed(*INFO);self.assertEqual(self.p.season[0],202610)
|
||||||
|
self.assertEqual(self.ns['Grouped'](50000000),'50,000,000');self.assertEqual(self.ns['Amount'](4,45,90),'0.8')
|
||||||
|
self.assertEqual(self.ns['Unit'](4,90),' 分钟');self.assertEqual(self.ns['Amount'](4,7200,172800),'2');self.assertEqual(self.ns['SeasonName'](7),'第 7 期')
|
||||||
|
|
||||||
|
def test_ticket_access_and_activation(self):
|
||||||
|
self.open()
|
||||||
|
self.feed(INFO[0],'MTBP1 access 0 10',*INFO[1:])
|
||||||
|
self.assertEqual((self.p.access,self.p.month),(0,10))
|
||||||
|
self.assertFalse(self.p.Claim(2))
|
||||||
|
self.assertTrue(self.w.activateButton.IsShow())
|
||||||
|
self.clock+=1
|
||||||
|
self.assertFalse(self.p.Activate(4,25159))
|
||||||
|
self.assertFalse(self.p.Activate(3,93110))
|
||||||
|
self.assertTrue(self.p.Activate(3,25159))
|
||||||
|
self.assertEqual(self.sent[-1],'/mt_bp 1 activate 3 25159')
|
||||||
|
self.assertFalse(self.p.Activate(3,25159))
|
||||||
|
self.feed(INFO[0],'MTBP1 access 1 10',*INFO[1:])
|
||||||
|
self.assertFalse(self.w.activateButton.IsShow())
|
||||||
|
self.clock+=1;self.assertTrue(self.p.Claim(2))
|
||||||
|
self.feed(INFO[0],'MTBP1 access 2 10',*INFO[1:])
|
||||||
|
self.clock+=1;self.assertFalse(self.p.Claim(2))
|
||||||
|
|
||||||
|
def test_registered(self):
|
||||||
|
boot=(ROOT/'src/platform/ScriptLib/PythonBoot.cpp').read_text()
|
||||||
|
self.assertIn('#include "BattlePassScript.inc"',boot)
|
||||||
|
self.assertLess(boot.index('install_script_module("mt_preview"'),boot.index('install_script_module("mt_battlepass"'))
|
||||||
|
self.game.GameWindow.Close(self.gw);self.assertIsNone(self.ns['g_window'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
@@ -37,6 +37,8 @@ class DisplayTests(unittest.TestCase):
|
|||||||
mods={n:types.ModuleType(n) for n in ('background','game','grp','systemSetting','ui','mt_biolog','mt_wiki')}
|
mods={n:types.ModuleType(n) for n in ('background','game','grp','systemSetting','ui','mt_biolog','mt_wiki')}
|
||||||
bg=mods['background'];bg.PART_TREE=3;bg.PART_CLOUD=2;bg.SetVisiblePart=lambda part,on:self.state['parts'].__setitem__(part,on)
|
bg=mods['background'];bg.PART_TREE=3;bg.PART_CLOUD=2;bg.SetVisiblePart=lambda part,on:self.state['parts'].__setitem__(part,on)
|
||||||
ss=mods['systemSetting'];ss.SetEffectLevel=lambda v:self.state.__setitem__('effect',v)
|
ss=mods['systemSetting'];ss.SetEffectLevel=lambda v:self.state.__setitem__('effect',v)
|
||||||
|
ss.SetShopNamesRange=lambda v:self.state.__setitem__('shopRange',v)
|
||||||
|
ss.SetShopNameStatus=lambda v:self.state.__setitem__('npcNames',v)
|
||||||
ss.IsAlwaysShowName=lambda:self.state['name'];ss.SetAlwaysShowNameFlag=lambda v:self.state.__setitem__('name',bool(v))
|
ss.IsAlwaysShowName=lambda:self.state['name'];ss.SetAlwaysShowNameFlag=lambda v:self.state.__setitem__('name',bool(v))
|
||||||
ss.IsShowDamage=lambda:self.state['damage'];ss.SetShowDamageFlag=lambda v:self.state.__setitem__('damage',bool(v))
|
ss.IsShowDamage=lambda:self.state['damage'];ss.SetShowDamageFlag=lambda v:self.state.__setitem__('damage',bool(v))
|
||||||
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:self.opened.append(1),Close=lambda s:None))
|
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:self.opened.append(1),Close=lambda s:None))
|
||||||
@@ -58,9 +60,9 @@ class DisplayTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_defaults_and_config(self):
|
def test_defaults_and_config(self):
|
||||||
ns=self.load();self.assertEqual(self.state['effect'],0)
|
ns=self.load();self.assertEqual(self.state['effect'],0)
|
||||||
o=ns['Options']();self.assertEqual(o.Dump(),'0 1 1')
|
o=ns['Options']();self.assertEqual(o.Dump(),'0 1 1 0 1')
|
||||||
for bad in ('5 1 1','1 2 1','a 1 1','1 1','10 1 1','-1 1 1'):
|
for bad in ('5 1 1','1 2 1','a 1 1','1 1','10 1 1','-1 1 1'):
|
||||||
o=ns['Options']();o.Load(bad);self.assertEqual(o.Dump(),'0 1 1')
|
o=ns['Options']();o.Load(bad);self.assertEqual(o.Dump(),'0 1 1 0 1')
|
||||||
Path('mt_display.cfg').write_text('3 0 1')
|
Path('mt_display.cfg').write_text('3 0 1')
|
||||||
self.load();self.assertEqual(self.state['effect'],3) # applied at boot, before any map
|
self.load();self.assertEqual(self.state['effect'],3) # applied at boot, before any map
|
||||||
|
|
||||||
@@ -74,7 +76,9 @@ class DisplayTests(unittest.TestCase):
|
|||||||
self.assertNotIn('【',w.treeButton.text);self.assertIn('【',w.cloudButton.text)
|
self.assertNotIn('【',w.treeButton.text);self.assertIn('【',w.cloudButton.text)
|
||||||
w.levelButtons[4].event();self.assertEqual(self.state['effect'],4)
|
w.levelButtons[4].event();self.assertEqual(self.state['effect'],4)
|
||||||
w.ToggleTree();w.ToggleCloud();self.assertEqual(self.state['parts'],{3:1,2:0})
|
w.ToggleTree();w.ToggleCloud();self.assertEqual(self.state['parts'],{3:1,2:0})
|
||||||
self.assertEqual(Path('mt_display.cfg').read_text(),'4 1 0')
|
self.assertEqual(Path('mt_display.cfg').read_text(),'4 1 0 0 1')
|
||||||
|
w.NextRange();w.ToggleNPC();self.assertEqual((self.state['shopRange'],self.state['npcNames']),(1,False))
|
||||||
|
self.assertEqual(Path('mt_display.cfg').read_text(),'4 1 0 1 0')
|
||||||
w.ToggleName();w.ToggleDamage();self.assertEqual((self.state['name'],self.state['damage']),(False,False))
|
w.ToggleName();w.ToggleDamage();self.assertEqual((self.state['name'],self.state['damage']),(False,False))
|
||||||
self.assertNotIn('【',w.nameButton.text)
|
self.assertNotIn('【',w.nameButton.text)
|
||||||
game.GameWindow.Close(gw);self.assertIsNone(ns['g_window']);self.assertIsNone(ns['g_entry'])
|
game.GameWindow.Close(gw);self.assertIsNone(ns['g_window']);self.assertIsNone(ns['g_entry'])
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class Widget:
|
||||||
|
def __init__(self,*args):self.shown=False;self.page=0;self.inventoryPageIndex=0;self.size=(360,400)
|
||||||
|
def __getattr__(self,name):return lambda *args:None
|
||||||
|
def Show(self):self.shown=True
|
||||||
|
def Hide(self):self.shown=False
|
||||||
|
def IsShow(self):return self.shown
|
||||||
|
def GetGlobalPosition(self):return (10,20)
|
||||||
|
def GetHeight(self):return self.size[1]
|
||||||
|
def GetWidth(self):return self.size[0]
|
||||||
|
def SetSize(self,w,h):self.size=w,h
|
||||||
|
|
||||||
|
|
||||||
|
class Action(Widget):
|
||||||
|
def __init__(self,parent,x,y,width,label,event):super().__init__();self.label=label;self.event=event
|
||||||
|
|
||||||
|
|
||||||
|
class ItemActionsTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
names=('game','grp','player','ui','wndMgr','mt_biolog','mt_preview','mt_chest','mt_wiki','uiInventory','mt_battlepass','mt_switchbot')
|
||||||
|
mods={name:types.ModuleType(name) for name in names};self.mods=mods;self.actions=[];self.bag={3:(50011,2),4:(19,1)}
|
||||||
|
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None))
|
||||||
|
mods['uiInventory'].InventoryWindow=type('InventoryWindow',(),dict(UseItemSlot=lambda owner,cell:self.actions.append(('use',cell)),_InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos=lambda owner,cell:cell))
|
||||||
|
mods['mt_battlepass'].g_window=types.SimpleNamespace(OfferTicket=lambda c,v:self.actions.append(('ticket',c,v)))
|
||||||
|
mods['mt_switchbot'].g_window=types.SimpleNamespace(Open=lambda:self.actions.append(('lock',)))
|
||||||
|
mods['ui'].Window=Widget;mods['mt_biolog'].Action=Action
|
||||||
|
mods['player'].GetItemIndex=lambda cell:self.bag.get(cell,(0,0))[0]
|
||||||
|
mods['player'].GetItemCount=lambda cell:self.bag.get(cell,(0,0))[1]
|
||||||
|
mods['wndMgr'].GetScreenWidth=lambda:1280;mods['wndMgr'].GetScreenHeight=lambda:720
|
||||||
|
mods['mt_preview'].ItemSpec=lambda v:v==19
|
||||||
|
mods['mt_preview'].Preview=lambda kind,v:self.actions.append(('preview',v))
|
||||||
|
mods['mt_chest'].Batchable=lambda v:v==50011
|
||||||
|
mods['mt_chest'].g_window=types.SimpleNamespace(Select=lambda c,v:self.actions.append(('chest',c,v)))
|
||||||
|
mods['mt_wiki'].OpenItem=lambda v:self.actions.append(('wiki',v))
|
||||||
|
self.previous={n:sys.modules.get(n) for n in mods};sys.modules.update(mods)
|
||||||
|
text=(ROOT/'src/platform/ScriptLib/ItemActionsScript.inc').read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
|
||||||
|
self.ns={'__name__':'mt_item_actions'};exec(text,self.ns)
|
||||||
|
self.game=mods['game'].GameWindow();self.game.Open();self.window=self.ns['g_window'];self.owner=Widget();self.owner.Show()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.game.Close()
|
||||||
|
for n,value in self.previous.items():
|
||||||
|
if value is None:sys.modules.pop(n,None)
|
||||||
|
else:sys.modules[n]=value
|
||||||
|
|
||||||
|
def test_chest_and_preview_actions(self):
|
||||||
|
self.ns['ShowInventory'](self.owner,3)
|
||||||
|
self.assertEqual([b.label for b in self.window.buttons],['百科','批量开箱','关闭'])
|
||||||
|
self.window.buttons[1].event();self.assertEqual(self.actions,[('chest',3,50011)])
|
||||||
|
self.ns['ShowInventory'](self.owner,4)
|
||||||
|
self.window.buttons[1].event();self.assertEqual(self.actions[-1],('preview',19))
|
||||||
|
|
||||||
|
def test_ticket_lock_and_original_use(self):
|
||||||
|
inventory=self.mods['uiInventory'].InventoryWindow()
|
||||||
|
for vnum in (25159,50348,50349,27001):
|
||||||
|
self.bag[5]=(vnum,1);inventory.UseItemSlot(5)
|
||||||
|
self.assertEqual(self.actions,[('ticket',5,25159),('lock',),('lock',),('use',5)])
|
||||||
|
|
||||||
|
def test_stale_actions_never_fire(self):
|
||||||
|
for change in ('page','vnum','count','closed'):
|
||||||
|
self.owner.Show();self.owner.inventoryPageIndex=0;self.bag[3]=(50011,2)
|
||||||
|
self.ns['ShowInventory'](self.owner,3);button=self.window.buttons[1]
|
||||||
|
if change=='page':self.owner.inventoryPageIndex=1
|
||||||
|
elif change=='vnum':self.bag[3]=(19,2)
|
||||||
|
elif change=='count':self.bag[3]=(50011,1)
|
||||||
|
else:self.owner.Hide()
|
||||||
|
button.event();self.assertEqual(self.actions,[]);self.assertFalse(self.window.IsShow())
|
||||||
|
self.owner.Show();self.ns['ShowInventory'](self.owner,3);self.owner.Hide();self.window.OnUpdate()
|
||||||
|
self.assertFalse(self.window.IsShow())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
def script(name):return (ROOT/'src/platform/ScriptLib'/name).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
class Widget(object):
|
||||||
|
def __init__(self,*a,**k):
|
||||||
|
self.Hide() # like ui.Window, before the subclass sets anything
|
||||||
|
self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0);self.enabled=True
|
||||||
|
def __getattr__(self,name):return lambda *a,**k:None
|
||||||
|
def Show(self):self.shown=True
|
||||||
|
def Hide(self):self.shown=False
|
||||||
|
def IsShow(self):return self.shown
|
||||||
|
def SetText(self,text):self.text=text
|
||||||
|
def SetPosition(self,x,y):self.pos=(x,y)
|
||||||
|
def GetGlobalPosition(self):return self.pos
|
||||||
|
def SetSize(self,w,h):self.size=(w,h)
|
||||||
|
def GetWidth(self):return self.size[0]
|
||||||
|
def GetHeight(self):return self.size[1]
|
||||||
|
|
||||||
|
|
||||||
|
class Action(Widget):
|
||||||
|
def __init__(self,parent,x,y,width,text,event):
|
||||||
|
Widget.__init__(self);self.pos=(x,y);self.event=event;self.text=text;self.shown=True
|
||||||
|
def SetEnabled(self,enabled):self.enabled=enabled
|
||||||
|
|
||||||
|
|
||||||
|
# vnum -> (type, subtype, antiflags)
|
||||||
|
ITEMS={11299:(1,0,0),11499:(1,0,0x08|0x10|0x20),12049:(2,0,0x04|0x10|0x20|0x40),8000:(1,6,0),
|
||||||
|
41001:(28,0,0x01),45001:(28,1,0x02),27001:(3,0,0),11259:(2,1,0)}
|
||||||
|
|
||||||
|
|
||||||
|
class Targets(object):
|
||||||
|
"""renderTarget stub: what exists, and every call in order."""
|
||||||
|
def __init__(self):self.live=set();self.calls=[];self.models={};self.missing=set();self.motionless=set()
|
||||||
|
def __getattr__(self,name):
|
||||||
|
def call(*a):self.calls.append((name,)+a)
|
||||||
|
return call
|
||||||
|
def Create(self,i,w,h):self.calls.append(('Create',i,w,h));self.live.add(i);return 1
|
||||||
|
def Remove(self,i):self.calls.append(('Remove',i));self.live.discard(i);self.models.pop(i,None)
|
||||||
|
def Destroy(self):self.calls.append(('Destroy',));self.live.clear();self.models.clear()
|
||||||
|
def SelectModel(self,i,race):
|
||||||
|
self.calls.append(('SelectModel',i,race))
|
||||||
|
if race in self.missing:return 0
|
||||||
|
self.models[i]=race;return 1
|
||||||
|
def SetMotionIndex(self,i,m):self.calls.append(('SetMotionIndex',i,m));return 0 if m in self.motionless else 1
|
||||||
|
def named(self,name):return [c for c in self.calls if c[0]==name]
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.chat=[];self.keys=set();self.mouse=(0,0);self.race=0;self.selected=None
|
||||||
|
self.rt=Targets()
|
||||||
|
names=('app','chat','game','grp','item','player','renderTarget','ui','wndMgr','mt_biolog','uiInventory','uiShop','shop')
|
||||||
|
mods={n:types.ModuleType(n) for n in names}
|
||||||
|
mods['renderTarget']=self.rt
|
||||||
|
app=mods['app'];app.DIK_X=45;app.IsPressed=lambda k:k in self.keys
|
||||||
|
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
|
||||||
|
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None))
|
||||||
|
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None
|
||||||
|
it=mods['item']
|
||||||
|
for k,v in dict(ITEM_TYPE_WEAPON=1,ITEM_TYPE_ARMOR=2,ITEM_TYPE_COSTUME=28,WEAPON_ARROW=6,ARMOR_BODY=0,
|
||||||
|
COSTUME_TYPE_BODY=0,COSTUME_TYPE_HAIR=1,ITEM_ANTIFLAG_FEMALE=0x01,ITEM_ANTIFLAG_MALE=0x02,
|
||||||
|
ITEM_ANTIFLAG_WARRIOR=0x04,ITEM_ANTIFLAG_ASSASSIN=0x08,ITEM_ANTIFLAG_SURA=0x10,
|
||||||
|
ITEM_ANTIFLAG_SHAMAN=0x20).items():setattr(it,k,v)
|
||||||
|
def select(v):self.selected=ITEMS.get(v,ITEMS[27001])
|
||||||
|
it.SelectItem=select;it.GetItemType=lambda:self.selected[0];it.GetItemSubType=lambda:self.selected[1]
|
||||||
|
it.IsAntiFlag=lambda f:bool(self.selected[2]&f)
|
||||||
|
mods['player'].GetRace=lambda:self.race
|
||||||
|
self.bag={3:11299,4:27001}
|
||||||
|
mods['player'].GetItemIndex=lambda cell:self.bag.get(cell,0)
|
||||||
|
mods['ui'].Window=Widget
|
||||||
|
mods['wndMgr'].GetMousePosition=lambda:self.mouse
|
||||||
|
mods['mt_biolog'].Action=Action;mods['mt_biolog'].L=lambda t:t
|
||||||
|
mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:self.text(t)
|
||||||
|
self.inventoryClicks=[];self.shopClicks=[]
|
||||||
|
mods['uiInventory'].InventoryWindow=type('InventoryWindow',(),dict(
|
||||||
|
SelectItemSlot=lambda s,slot:self.inventoryClicks.append(slot),
|
||||||
|
_InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos=lambda s,slot:slot+0))
|
||||||
|
mods['uiShop'].ShopDialog=type('ShopDialog',(),dict(SelectItemSlot=lambda s,slot:self.shopClicks.append(slot),
|
||||||
|
_ShopDialog__GetRealIndex=lambda s,slot:40+slot))
|
||||||
|
mods['shop'].GetItemID=lambda i:{41:12049,42:27001}.get(i,0)
|
||||||
|
self.previous={n:sys.modules.get(n) for n in mods}
|
||||||
|
sys.modules.update(mods)
|
||||||
|
self.ns={'__name__':'mt_preview'};exec(script('ModelPreviewScript.inc'),self.ns)
|
||||||
|
self.game=mods['game'];self.gw=self.game.GameWindow();self.game.GameWindow.Open(self.gw)
|
||||||
|
self.w=self.ns['g_window']
|
||||||
|
|
||||||
|
def text(self,t):
|
||||||
|
w=Widget();w.text=t;return w
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for name,value in self.previous.items():
|
||||||
|
if value is None:sys.modules.pop(name,None)
|
||||||
|
else:sys.modules[name]=value
|
||||||
|
|
||||||
|
def test_item_spec(self):
|
||||||
|
spec=self.ns['ItemSpec']
|
||||||
|
self.assertEqual(spec(11299),('weapon',[0,4,5,1,2,6,7,3]))
|
||||||
|
self.assertEqual(spec(11499),('weapon',[0,4])) # warrior only
|
||||||
|
self.assertEqual(spec(12049),('armor',[5,1])) # assassin only
|
||||||
|
self.assertEqual(spec(41001),('armor',[0,5,2,7])) # anti-female costume
|
||||||
|
self.assertEqual(spec(45001),('hair',[4,1,6,3]))
|
||||||
|
for vnum in (8000,27001,11259):self.assertIsNone(spec(vnum)) # arrow, potion, helmet
|
||||||
|
self.assertTrue(self.ns['Previewable']('mob',101));self.assertFalse(self.ns['Previewable']('item',27001))
|
||||||
|
|
||||||
|
def test_item_preview_races_and_reset(self):
|
||||||
|
self.race=2
|
||||||
|
self.assertTrue(self.ns['Preview']('item',11299));self.assertTrue(self.w.IsShow())
|
||||||
|
self.assertEqual(self.rt.live,{1});self.assertIn(('Create',1,300,330),self.rt.calls)
|
||||||
|
self.assertEqual(self.rt.models[1],2);self.assertEqual(self.rt.named('SetWeapon')[-1],('SetWeapon',1,11299))
|
||||||
|
self.assertEqual(self.w.raceName.text,'修罗(男)');self.assertEqual(self.rt.named('Zoom'),[('Zoom',1,1)]*2)
|
||||||
|
self.w.nextRace.event();self.assertEqual(self.rt.models[1],6);self.assertEqual(self.w.raceName.text,'修罗(女)')
|
||||||
|
self.w.prevRace.event();self.w.prevRace.event();self.assertEqual(self.rt.models[1],1)
|
||||||
|
# The player's own race is not allowed: the first allowed race is shown, and race buttons need two races.
|
||||||
|
self.ns['Preview']('item',41001);self.assertEqual(self.rt.models[1],2)
|
||||||
|
self.assertEqual(self.rt.named('SetArmor')[-1],('SetArmor',1,41001))
|
||||||
|
self.ns['Preview']('item',12049);self.assertEqual(self.rt.models[1],5)
|
||||||
|
self.ns['Preview']('item',45001);self.assertEqual(self.rt.models[1],4)
|
||||||
|
self.assertEqual(self.rt.named('SetHair')[-1],('SetHair',1,45001))
|
||||||
|
self.assertTrue(self.w.nextRace.enabled)
|
||||||
|
self.ns['Preview']('item',11499);self.race=0;self.ns['Preview']('item',11499)
|
||||||
|
self.assertEqual(self.rt.models[1],0)
|
||||||
|
self.assertFalse(self.ns['Preview']('item',27001));self.assertEqual(self.chat,['该物品没有可预览的模型'])
|
||||||
|
self.assertEqual(self.rt.models[1],0) # the window keeps what it showed
|
||||||
|
|
||||||
|
def test_mob_motions_zoom_turn(self):
|
||||||
|
self.rt.motionless.add(34)
|
||||||
|
self.assertTrue(self.ns['Preview']('mob',101));self.assertEqual(self.rt.models[1],101)
|
||||||
|
self.assertFalse(self.w.prevRace.IsShow());self.assertTrue(all(b.IsShow() for b in self.w.motions))
|
||||||
|
self.w.motions[1].event();self.assertEqual(self.rt.named('SetMotionIndex')[-1],('SetMotionIndex',1,13))
|
||||||
|
self.w.motions[5].event();self.assertEqual(self.w.note.text,'该怪物没有此动作')
|
||||||
|
self.w.motions[0].event();self.assertEqual(self.w.note.text,'')
|
||||||
|
zoom_in,zoom_out,spin,reset=[b.event for b in self.w.controls]
|
||||||
|
for _ in range(20):zoom_in()
|
||||||
|
self.assertEqual(self.w.zoom,-8);self.assertEqual(len(self.rt.named('Zoom')),8)
|
||||||
|
for _ in range(30):zoom_out()
|
||||||
|
self.assertEqual(self.w.zoom,12);self.assertEqual(len(self.rt.named('Zoom')),28)
|
||||||
|
spin();self.assertEqual(self.rt.named('SetRotation')[-1],('SetRotation',1,True));self.assertEqual(self.w.controls[2].text,'停止旋转')
|
||||||
|
# Drag: absolute yaw/pitch from where the press started, pitch clamped.
|
||||||
|
view=self.w.view;self.mouse=(100,100);view.OnMouseLeftButtonDown()
|
||||||
|
self.mouse=(160,1100);view.OnUpdate()
|
||||||
|
self.assertEqual(self.rt.named('RotateEyeAroundTarget')[-1],('RotateEyeAroundTarget',1,60.,30.))
|
||||||
|
view.OnMouseLeftButtonUp();self.mouse=(0,0);view.OnUpdate()
|
||||||
|
self.assertEqual(self.rt.named('RotateEyeAroundTarget')[-1],('RotateEyeAroundTarget',1,60.,30.))
|
||||||
|
reset();self.assertEqual((self.w.yaw,self.w.pitch,self.w.zoom),(0.,0.,0))
|
||||||
|
self.assertEqual(self.rt.named('SetRotation')[-1],('SetRotation',1,True)) # spin survives the reset
|
||||||
|
self.rt.missing.add(999);self.ns['Preview']('mob',999);self.assertEqual(self.w.note.text,'该怪物没有模型')
|
||||||
|
zoom_in();self.assertEqual(self.w.zoom,0)
|
||||||
|
|
||||||
|
def test_view_draws_target(self):
|
||||||
|
self.ns['Preview']('mob',101);self.w.view.pos=(20,70);self.w.view.OnRender()
|
||||||
|
self.assertEqual(self.rt.named('RenderTexture')[-1],('RenderTexture',1,20,70,320,400))
|
||||||
|
|
||||||
|
def test_no_leak_on_reopen_and_close(self):
|
||||||
|
for _ in range(5):
|
||||||
|
self.ns['Preview']('item',11299);self.assertEqual(self.rt.live,{1})
|
||||||
|
self.w.closeButton.event();self.assertEqual(self.rt.live,set());self.assertFalse(self.w.IsShow())
|
||||||
|
self.ns['Preview']('mob',101);self.w.OnPressEscapeKey();self.assertEqual(self.rt.live,set())
|
||||||
|
self.ns['Preview']('mob',101)
|
||||||
|
self.game.GameWindow.Close(self.gw)
|
||||||
|
self.assertIsNone(self.ns['g_window']);self.assertEqual(self.rt.live,set());self.assertEqual(self.rt.calls[-1],('Destroy',))
|
||||||
|
self.assertFalse(self.ns['Preview']('mob',101))
|
||||||
|
|
||||||
|
def test_inventory_and_shop_entries(self):
|
||||||
|
inv=sys.modules['uiInventory'].InventoryWindow()
|
||||||
|
inv.SelectItemSlot(3);self.assertEqual(self.inventoryClicks,[3]);self.assertFalse(self.w.IsShow())
|
||||||
|
self.keys.add(45)
|
||||||
|
inv.SelectItemSlot(3);self.assertEqual(self.inventoryClicks,[3]);self.assertTrue(self.w.IsShow())
|
||||||
|
inv.SelectItemSlot(4);inv.SelectItemSlot(9);self.assertEqual(self.inventoryClicks,[3,4,9]) # not wearable, empty
|
||||||
|
dlg=sys.modules['uiShop'].ShopDialog()
|
||||||
|
dlg.SelectItemSlot(1);self.assertEqual(self.shopClicks,[]);self.assertEqual(self.rt.named('SetArmor')[-1],('SetArmor',1,12049))
|
||||||
|
dlg.SelectItemSlot(2);self.assertEqual(self.shopClicks,[2])
|
||||||
|
self.keys.clear();dlg.SelectItemSlot(1);self.assertEqual(self.shopClicks,[2,1])
|
||||||
|
|
||||||
|
def test_wiki_links(self):
|
||||||
|
wiki=(ROOT/'src/platform/ScriptLib/WikiScript.inc').read_text()
|
||||||
|
self.assertIn("(u'模型预览',('preview','item',vnum))",wiki);self.assertIn("(u'模型预览',('preview','mob',vnum))",wiki)
|
||||||
|
self.assertIn("mt_preview.Preview(vnum[1],vnum[2])",wiki)
|
||||||
|
boot=(ROOT/'src/platform/ScriptLib/PythonBoot.cpp').read_text()
|
||||||
|
self.assertLess(boot.index('install_script_module("mt_mobile_inventory"'),boot.index('install_script_module("mt_preview"'))
|
||||||
|
self.assertLess(boot.index('install_script_module("mt_biolog"'),boot.index('install_script_module("mt_preview"'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
def script(name):return (ROOT/'src/platform/ScriptLib'/name).read_text().split('R"PY(',1)[1].rsplit(')PY";',1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
class Widget(object):
|
||||||
|
def __init__(self,*a,**k):self.shown=False;self.text='';self.pos=(0,0);self.size=(0,0);self.color=0
|
||||||
|
def __getattr__(self,name):return lambda *a,**k:None
|
||||||
|
def Show(self):self.shown=True
|
||||||
|
def Hide(self):self.shown=False
|
||||||
|
def IsShow(self):return self.shown
|
||||||
|
def SetText(self,text):self.text=text
|
||||||
|
def SetPackedFontColor(self,color):self.color=color
|
||||||
|
def SetPosition(self,x,y):self.pos=(x,y)
|
||||||
|
def GetGlobalPosition(self):return self.pos
|
||||||
|
def SetSize(self,w,h):self.size=(w,h)
|
||||||
|
def GetWidth(self):return self.size[0]
|
||||||
|
def GetHeight(self):return self.size[1]
|
||||||
|
|
||||||
|
|
||||||
|
class Action(Widget):
|
||||||
|
def __init__(self,parent,x,y,width,text,event):
|
||||||
|
Widget.__init__(self);self.pos=(x,y);self.event=event;self.text=text;self.shown=True
|
||||||
|
|
||||||
|
|
||||||
|
class Minimap(Widget):
|
||||||
|
def GetGlobalPosition(self):return (700,10)
|
||||||
|
|
||||||
|
|
||||||
|
class VipTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.time=100.;self.sent=[];self.chat=[]
|
||||||
|
mods={n:types.ModuleType(n) for n in ('app','chat','game','grp','net','ui','mt_biolog')}
|
||||||
|
mods['app'].GetTime=lambda:self.time
|
||||||
|
mods['chat'].CHAT_TYPE_INFO=1;mods['chat'].AppendChat=lambda t,m:self.chat.append(m)
|
||||||
|
mods['net'].SendChatPacket=self.sent.append
|
||||||
|
mods['game'].GameWindow=type('GameWindow',(),dict(Open=lambda s:None,Close=lambda s:None,
|
||||||
|
BINARY_ServerCommand_Run=lambda s,line:7))
|
||||||
|
grp=mods['grp'];grp.SetColor=grp.RenderBar=grp.RenderBox=lambda *a:None
|
||||||
|
mods['ui'].Window=Widget
|
||||||
|
mods['mt_biolog'].Action=Action;mods['mt_biolog'].L=lambda t:t
|
||||||
|
mods['mt_biolog'].Text=lambda p,x,y,t='',c=0:self.text(t,c)
|
||||||
|
self.previous={n:sys.modules.get(n) for n in mods}
|
||||||
|
sys.modules.update(mods)
|
||||||
|
self.ns={'__name__':'mt_vip'};exec(script('VipScript.inc'),self.ns)
|
||||||
|
self.status=self.ns['Status'](self.sent.append,lambda:self.time)
|
||||||
|
|
||||||
|
def text(self,t,c):
|
||||||
|
w=Widget();w.text=t;w.color=c;return w
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for name,value in self.previous.items():
|
||||||
|
if value is None:sys.modules.pop(name,None)
|
||||||
|
else:sys.modules[name]=value
|
||||||
|
|
||||||
|
def test_query_parses_times_and_counts_down(self):
|
||||||
|
self.assertTrue(self.status.Query());self.assertFalse(self.status.Query())
|
||||||
|
self.assertEqual(self.sent,['/mt_vip 1 status'])
|
||||||
|
self.assertTrue(self.status.Receive('MTVIP1 times 604800 604800 0 604800 604800 604800 90'))
|
||||||
|
self.assertIsNone(self.status.waiting);self.assertEqual(self.status.Left(2),0)
|
||||||
|
self.time+=100;self.assertEqual(self.status.Left(0),604700);self.assertEqual(self.status.Left(6),0)
|
||||||
|
self.time+=.5;self.assertTrue(self.status.Query()) # past the send gap
|
||||||
|
|
||||||
|
def test_added_error_malformed_and_foreign(self):
|
||||||
|
events=[];self.status.listener=events.append
|
||||||
|
self.status.Receive('MTVIP1 added 38002 30');self.assertEqual(self.status.added,(38002,30))
|
||||||
|
self.status.Receive('MTVIP1 error full');self.assertEqual(self.status.error,'full')
|
||||||
|
for bad in ('MTVIP1 times 1 2 3','MTVIP1 times 1 2 3 4 5 6 x','MTVIP1 times 1 2 3 4 5 6 -7','MTVIP1 added 38001 0',
|
||||||
|
'MTVIP1 added 38001 401','MTVIP1 error boom','MTVIP1 boom'):
|
||||||
|
self.assertTrue(self.status.Receive(bad))
|
||||||
|
self.assertEqual(events,['added','error']);self.assertIsNone(self.status.times)
|
||||||
|
self.assertFalse(self.status.Receive('MTCH1 error kind'))
|
||||||
|
|
||||||
|
def test_timeout_marks_unsupported(self):
|
||||||
|
events=[];self.status.listener=events.append
|
||||||
|
self.status.Query();self.time+=3.1;self.status.Tick()
|
||||||
|
self.assertTrue(self.status.unsupported);self.assertEqual(events,['unsupported'])
|
||||||
|
self.status.Receive('MTVIP1 times 0 0 0 0 0 0 0');self.assertFalse(self.status.unsupported)
|
||||||
|
|
||||||
|
def test_remaining_text(self):
|
||||||
|
R=self.ns['Remaining']
|
||||||
|
self.assertEqual(R(0),'未开通');self.assertEqual(R(-5),'未开通')
|
||||||
|
self.assertEqual(R(7*86400+3*3600+59),'剩余 7 天 3 小时')
|
||||||
|
self.assertEqual(R(5*3600+120),'剩余 5 小时 2 分');self.assertEqual(R(30),'剩余 1 分')
|
||||||
|
|
||||||
|
def test_window_entry_and_chat(self):
|
||||||
|
game=sys.modules['game'];gw=game.GameWindow()
|
||||||
|
gw.interface=types.SimpleNamespace(wndMiniMap=Minimap())
|
||||||
|
game.GameWindow.Open(gw);w=self.ns['g_window'];entry=self.ns['g_entry']
|
||||||
|
self.assertEqual(entry.pos,(632,418))
|
||||||
|
entry.event();self.assertTrue(w.IsShow());self.assertEqual(self.sent,['/mt_vip 1 status'])
|
||||||
|
self.assertEqual(w.note.text,'正在查询……');self.assertEqual(w.values[0].text,'……')
|
||||||
|
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 times 0 0 0 0 0 0 0'),1)
|
||||||
|
self.assertEqual(w.note.text,'尚未开通 VIP');self.assertEqual(w.values[3].text,'未开通')
|
||||||
|
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 added 38001 7'),1)
|
||||||
|
self.assertEqual(self.chat,['VIP 会员已延长 7 天,全部特权立即生效'])
|
||||||
|
game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 times 604800 604800 604800 604800 604800 604800 604800')
|
||||||
|
self.assertEqual(w.values[6].text,'剩余 7 天 0 小时');self.assertEqual(w.note.text,'VIP 生效中')
|
||||||
|
game.GameWindow.BINARY_ServerCommand_Run(gw,'MTVIP1 error full')
|
||||||
|
self.assertEqual(self.chat[-1],'VIP 剩余时间不能超过 400 天');self.assertIn('400',w.note.text)
|
||||||
|
self.assertEqual(game.GameWindow.BINARY_ServerCommand_Run(gw,'other'),7)
|
||||||
|
entry.event();self.assertFalse(w.IsShow())
|
||||||
|
self.time+=2;entry.event();self.time+=3.1;w.OnUpdate()
|
||||||
|
self.assertEqual(w.note.text,'!服务器尚未启用 VIP 卡')
|
||||||
|
game.GameWindow.Close(gw);self.assertIsNone(self.ns['g_window']);self.assertIsNone(self.ns['g_entry'])
|
||||||
|
|
||||||
|
def test_server_constants_match(self):
|
||||||
|
server=ROOT.parent/'metin2-server/src/server/game/src/port_premium.h'
|
||||||
|
if not server.exists():self.skipTest('server checkout not installed')
|
||||||
|
text=server.read_text(encoding='latin1')
|
||||||
|
self.assertIn('kPortVipMaxDays=400',text);self.assertEqual(self.ns['MAX_DAYS'],400)
|
||||||
|
self.assertIn('kPortVipGapMs=1000',text);self.assertEqual(self.ns['SEND_GAP'],1.)
|
||||||
|
cards={int(v):int(d) for v,d in re.findall(r'\{(\d+),(\d+)\}',text.split('kPortVipCards',1)[1].split(';',1)[0])}
|
||||||
|
self.assertEqual(cards,self.ns['CARDS'])
|
||||||
|
self.assertEqual(text.count('_expire"'),len(self.ns['SERVICES']))
|
||||||
|
self.assertTrue(set(re.findall(r'MTVIP1 error ([a-z]+)',text))<=set(self.ns['ERRORS']))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
+5148
File diff suppressed because it is too large
Load Diff
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/*", "tools/meshport-test-server", "tools/meshport-vpn-helper"]
|
||||||
|
exclude = ["vendor/easytier"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.95"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
|
||||||
|
easytier = { path = "vendor/easytier/easytier", default-features = false, features = ["aes-gcm", "ffi-dataplane", "proxy-cidr-monitor", "dhcp-ipv4", "tcp-hole-punch"] }
|
||||||
|
easytier-core = { path = "vendor/easytier/easytier-core", default-features = false, features = ["proxy-smoltcp-stack"] }
|
||||||
|
meshport = { path = "crates/meshport" }
|
||||||
|
meshport-easytier = { path = "crates/meshport-easytier" }
|
||||||
|
meshport-netstack = { path = "crates/meshport-netstack" }
|
||||||
|
meshport-socks = { path = "crates/meshport-socks" }
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
# MeshPort SDK snapshot
|
||||||
|
|
||||||
|
Source: MeshPort commit `b656c1896a1079f3284b999ecd7a3d650c23ac2a`.
|
||||||
|
Includes the Rust SDK, C ABI, SOCKS bridge and vendored EasyTier source.
|
||||||
|
Flutter UI, proxy engines, private profiles and compiled artifacts are excluded.
|
||||||
|
Build with Rust 1.95+ and protoc; Cargo.lock pins registry dependencies.
|
||||||
|
Upstream EasyTier license: `vendor/easytier/LICENSE`.
|
||||||
|
|
||||||
|
Local integration fix: `crates/meshport-easytier/src/lib.rs` refreshes the remote-subnet filter from the live DHCP address; the upstream filter captured only the initial static IP. Real DHCP plus subnet TCP/UDP coverage is in `tests/meshport/test_network_paths.py`.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "meshport-easytier"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
easytier.workspace = true
|
||||||
|
easytier-core.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
|
||||||
|
serde.workspace = true
|
||||||
|
cidr = "0.3"
|
||||||
|
network-interface = "2.0.5"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
system-vpn = ["easytier/tun", "easytier/proxy-cidr-monitor", "easytier/dns-resolver"]
|
||||||
|
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
easytier = { workspace = true, features = ["smoltcp", "proxy-packet"] }
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
//! Isolated USB/adb-reverse fixture, not a production relay or user network.
|
||||||
|
use easytier::instance::factory::create_native_instance;
|
||||||
|
use easytier_core::config::toml::{ConfigLoader, TomlConfig};
|
||||||
|
use easytier_core::instance::CoreInstanceConfig;
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
net::{TcpListener, UdpSocket},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let config = TomlConfig::new_from_str(
|
||||||
|
"ipv4 = '10.253.83.2/24'\nlisteners = ['tcp://127.0.0.1:18080']\nstun_servers = []\nstun_servers_v6 = []\n[[proxy_network]]\ncidr = '127.0.0.0/24'\nmapped_cidr = '10.253.79.0/24'\n[network_identity]\nnetwork_name = 'android-validation'\nnetwork_secret = 'test-only'\n[flags]\nno_tun = true\nuse_smoltcp = true",
|
||||||
|
)?;
|
||||||
|
let server = create_native_instance(config.clone())?;
|
||||||
|
server.start().await?;
|
||||||
|
let tcp = TcpListener::bind("127.0.0.1:17007").await?;
|
||||||
|
let udp = UdpSocket::bind("127.0.0.1:17007").await?;
|
||||||
|
let control = TcpListener::bind("127.0.0.1:18081").await?;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok((mut stream, _)) = tcp.accept().await {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buffer = [0; 4096];
|
||||||
|
while let Ok(n) = stream.read(&mut buffer).await {
|
||||||
|
if n == 0 || stream.write_all(&buffer[..n]).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buffer = [0; 4096];
|
||||||
|
while let Ok((n, peer)) = udp.recv_from(&mut buffer).await {
|
||||||
|
let _ = udp.send_to(&buffer[..n], peer).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let gateway = server.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok((mut stream, _)) = control.accept().await {
|
||||||
|
let mut buffer = [0; 32];
|
||||||
|
if stream.read(&mut buffer).await.is_ok() {
|
||||||
|
config.clear_proxy_cidrs();
|
||||||
|
let updated = CoreInstanceConfig::from_toml(&config).unwrap();
|
||||||
|
gateway
|
||||||
|
.update_runtime_config(
|
||||||
|
easytier_core::config::runtime::CoreInstanceRuntimeConfig {
|
||||||
|
services: updated.connectivity.runtime,
|
||||||
|
peer: std::sync::Arc::new(updated.peer.snapshot),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let _ = stream.write_all(b"withdrawn\n").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
println!("Android validation fixture ready on localhost:18080; echo subnet 10.253.79.0/24");
|
||||||
|
tokio::signal::ctrl_c().await?;
|
||||||
|
server.stop().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use easytier::socket_protector::{NativeSocketProtector, set_native_socket_protector};
|
||||||
|
use std::{io, sync::Arc};
|
||||||
|
|
||||||
|
pub type ProtectSocket = extern "C" fn(i32) -> bool;
|
||||||
|
struct AndroidProtector(ProtectSocket);
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl NativeSocketProtector for AndroidProtector {
|
||||||
|
async fn protect(&self, socket_handle: u64) -> io::Result<()> {
|
||||||
|
let fd = i32::try_from(socket_handle)
|
||||||
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid socket fd"))?;
|
||||||
|
if (self.0)(fd) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::PermissionDenied,
|
||||||
|
"Android VPN socket protection failed",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn install_socket_protector(callback: ProtectSocket) {
|
||||||
|
set_native_socket_protector(Some(Arc::new(AndroidProtector(callback))));
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! Structured profile <-> TOML. Hosts edit a JSON draft; the SDK owns the
|
||||||
|
//! mode invariants and the exact configuration text it will accept.
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Keys removed from saved configurations by earlier releases.
|
||||||
|
const LEGACY_KEYS: [&str; 1] = ["subnet_routes"];
|
||||||
|
|
||||||
|
/// Builds a validated configuration from a form draft. Any `runtime_mode` other
|
||||||
|
/// than `system_vpn` (including a host's unified VPN) runs the mesh in
|
||||||
|
/// application mode; transport and encryption invariants are not host choices.
|
||||||
|
pub fn build_config(profile: &Value) -> Result<String> {
|
||||||
|
let profile = profile.as_object().context("profile must be an object")?;
|
||||||
|
let text = |key: &str, default: &str| -> Result<Value> {
|
||||||
|
match profile.get(key) {
|
||||||
|
None | Some(Value::Null) => Ok(Value::from(default)),
|
||||||
|
Some(value @ Value::String(_)) => Ok(value.clone()),
|
||||||
|
Some(_) => bail!("{key} must be a string"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let system = profile.get("runtime_mode").and_then(Value::as_str) == Some("system_vpn");
|
||||||
|
let mut optimization = match profile.get("optimization") {
|
||||||
|
None | Some(Value::Null) => serde_json::Map::new(),
|
||||||
|
Some(Value::Object(map)) => map.clone(),
|
||||||
|
Some(_) => bail!("optimization must be an object"),
|
||||||
|
};
|
||||||
|
optimization
|
||||||
|
.entry("mode")
|
||||||
|
.or_insert_with(|| Value::from("adaptive"));
|
||||||
|
let latency_first = matches!(
|
||||||
|
optimization.get("mode").and_then(Value::as_str),
|
||||||
|
Some("adaptive" | "latency")
|
||||||
|
);
|
||||||
|
let mut flags = match profile.get("flags") {
|
||||||
|
None | Some(Value::Null) => serde_json::Map::new(),
|
||||||
|
Some(Value::Object(map)) => map.clone(),
|
||||||
|
Some(_) => bail!("flags must be an object"),
|
||||||
|
};
|
||||||
|
flags.insert("no_tun".into(), Value::from(!system));
|
||||||
|
flags.insert("use_smoltcp".into(), Value::from(!system));
|
||||||
|
flags.insert("enable_encryption".into(), Value::from(true));
|
||||||
|
flags.insert(
|
||||||
|
"enable_ipv6".into(),
|
||||||
|
profile
|
||||||
|
.get("enable_ipv6")
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::from(true)),
|
||||||
|
);
|
||||||
|
flags.insert("latency_first".into(), Value::from(latency_first));
|
||||||
|
|
||||||
|
let mut root = serde_json::Map::new();
|
||||||
|
root.insert("hostname".into(), text("hostname", "MeshPort")?);
|
||||||
|
if profile.get("dhcp").and_then(Value::as_bool) == Some(true) {
|
||||||
|
root.insert("dhcp".into(), Value::from(true));
|
||||||
|
} else {
|
||||||
|
root.insert("ipv4".into(), text("ipv4", "")?);
|
||||||
|
}
|
||||||
|
root.insert(
|
||||||
|
"listeners".into(),
|
||||||
|
profile
|
||||||
|
.get("listeners")
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Array(vec![])),
|
||||||
|
);
|
||||||
|
for key in ["stun_servers", "stun_servers_v6"] {
|
||||||
|
if let Some(value) = profile.get(key).filter(|v| !v.is_null()) {
|
||||||
|
root.insert(key.into(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let peers = match profile.get("peers") {
|
||||||
|
None | Some(Value::Null) => vec![],
|
||||||
|
Some(Value::Array(peers)) => peers
|
||||||
|
.iter()
|
||||||
|
.map(|uri| serde_json::json!({"uri": uri}))
|
||||||
|
.collect(),
|
||||||
|
Some(_) => bail!("peers must be an array"),
|
||||||
|
};
|
||||||
|
if !peers.is_empty() {
|
||||||
|
root.insert("peer".into(), Value::Array(peers));
|
||||||
|
}
|
||||||
|
root.insert(
|
||||||
|
"network_identity".into(),
|
||||||
|
serde_json::json!({
|
||||||
|
"network_name": text("network_name", "")?,
|
||||||
|
"network_secret": text("network_secret", "")?,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
root.insert("flags".into(), Value::Object(drop_nulls(flags)));
|
||||||
|
root.insert(
|
||||||
|
"runtime".into(),
|
||||||
|
serde_json::json!({"mode": if system { "system_vpn" } else { "application" }}),
|
||||||
|
);
|
||||||
|
root.insert(
|
||||||
|
"optimization".into(),
|
||||||
|
Value::Object(drop_nulls(optimization)),
|
||||||
|
);
|
||||||
|
let value = toml::Value::try_from(Value::Object(root))
|
||||||
|
.map_err(|_| anyhow::anyhow!("profile contains values TOML cannot represent"))?;
|
||||||
|
let output =
|
||||||
|
toml::to_string(&value).map_err(|_| anyhow::anyhow!("cannot encode configuration"))?;
|
||||||
|
super::parse_config(&output)?;
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop_nulls(map: serde_json::Map<String, Value>) -> serde_json::Map<String, Value> {
|
||||||
|
map.into_iter().filter(|(_, v)| !v.is_null()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes options earlier releases saved but this SDK no longer accepts.
|
||||||
|
/// Returns the input unchanged (formatting preserved) when nothing is removed.
|
||||||
|
pub fn migrate_config(input: &str) -> Result<(String, bool)> {
|
||||||
|
let mut value: toml::Value =
|
||||||
|
toml::from_str(input).map_err(|_| anyhow::anyhow!("invalid TOML configuration"))?;
|
||||||
|
let table = value
|
||||||
|
.as_table_mut()
|
||||||
|
.context("configuration must be a TOML table")?;
|
||||||
|
let mut changed = false;
|
||||||
|
for key in LEGACY_KEYS {
|
||||||
|
changed |= table.remove(key).is_some();
|
||||||
|
if let Some(runtime) = table.get_mut("runtime").and_then(|v| v.as_table_mut()) {
|
||||||
|
changed |= runtime.remove(key).is_some();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return Ok((input.to_owned(), false));
|
||||||
|
}
|
||||||
|
let output =
|
||||||
|
toml::to_string(&value).map_err(|_| anyhow::anyhow!("cannot encode configuration"))?;
|
||||||
|
Ok((output, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::ConfigLoader;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn draft() -> Value {
|
||||||
|
json!({
|
||||||
|
"runtime_mode": "unified_vpn",
|
||||||
|
"ipv4": "10.77.0.11/24",
|
||||||
|
"network_name": "test",
|
||||||
|
"network_secret": "test-only",
|
||||||
|
"flags": {"disable_p2p": true, "foreign_relay_bps_limit": "18446744073709551615", "no_tun": false},
|
||||||
|
"listeners": ["udp://0.0.0.0:11010"],
|
||||||
|
"stun_servers": [],
|
||||||
|
"peers": ["tcp://192.0.2.1:11010"],
|
||||||
|
"optimization": {"mode": "direct"},
|
||||||
|
"unknown_projection_field": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_modes_force_invariants_and_round_trip() {
|
||||||
|
let toml = build_config(&draft()).unwrap();
|
||||||
|
let config = crate::parse_config(&toml).unwrap();
|
||||||
|
let flags = config.get_flags();
|
||||||
|
assert!(flags.no_tun && flags.use_smoltcp && flags.enable_encryption);
|
||||||
|
assert!(flags.disable_p2p && !flags.latency_first && flags.enable_ipv6);
|
||||||
|
assert_eq!(flags.foreign_relay_bps_limit, u64::MAX);
|
||||||
|
assert_eq!(config.get_peers().len(), 1);
|
||||||
|
assert!(toml.contains("stun_servers = []"));
|
||||||
|
assert!(toml.contains("mode = \"application\""));
|
||||||
|
|
||||||
|
let mut system = draft();
|
||||||
|
system["runtime_mode"] = json!("system_vpn");
|
||||||
|
let config = crate::parse_config(&build_config(&system).unwrap()).unwrap();
|
||||||
|
assert!(!config.get_flags().no_tun && !config.get_flags().use_smoltcp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dhcp_and_defaults() {
|
||||||
|
let toml = build_config(&json!({"dhcp": true, "ipv4": "", "network_name": "n"})).unwrap();
|
||||||
|
let config = crate::parse_config(&toml).unwrap();
|
||||||
|
assert!(config.get_dhcp() && config.get_flags().latency_first);
|
||||||
|
assert_eq!(config.get_hostname(), "MeshPort");
|
||||||
|
assert!(build_config(&json!({"network_name": "n", "ipv4": 7})).is_err());
|
||||||
|
assert!(build_config(&json!({"network_name": ""})).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_subnet_routes_are_removed_only_when_present() {
|
||||||
|
let base = "ipv4 = '10.77.0.11'\n[network_identity]\nnetwork_name = 'test'\n";
|
||||||
|
assert_eq!(migrate_config(base).unwrap(), (base.to_owned(), false));
|
||||||
|
for legacy in [
|
||||||
|
format!("subnet_routes = ['192.168.21.0/24']\n{base}"),
|
||||||
|
format!("{base}[runtime]\nmode = 'system_vpn'\nsubnet_routes = ['192.168.21.0/24']\n"),
|
||||||
|
] {
|
||||||
|
assert!(crate::parse_config(&legacy).is_err());
|
||||||
|
let (migrated, changed) = migrate_config(&legacy).unwrap();
|
||||||
|
assert!(changed && !migrated.contains("subnet_routes"));
|
||||||
|
crate::parse_config(&migrated).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
|||||||
|
use cidr::Ipv4Cidr;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
|
fn overlaps(base: Ipv4Addr, prefix: u8, other: Ipv4Addr, other_mask: u32) -> bool {
|
||||||
|
let mask = u32::MAX.checked_shl(32 - u32::from(prefix)).unwrap_or(0) & other_mask;
|
||||||
|
u32::from(base) & mask == u32::from(other) & mask
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permitted(route: &Ipv4Cidr, overlay: Ipv4Addr, prefix: u8) -> bool {
|
||||||
|
(16..=32).contains(&route.network_length())
|
||||||
|
&& route.first_address().is_private()
|
||||||
|
&& !overlaps(
|
||||||
|
route.first_address(),
|
||||||
|
route.network_length(),
|
||||||
|
overlay,
|
||||||
|
u32::MAX.checked_shl(32 - u32::from(prefix)).unwrap_or(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allowed(route: &Ipv4Cidr, overlay: Ipv4Addr, prefix: u8) -> bool {
|
||||||
|
if !permitted(route, overlay, prefix) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
local_interfaces_allow(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overlay network when it can carry routed host traffic: a private IPv4 /16-/32.
|
||||||
|
pub fn routable_overlay(address: Ipv4Addr, prefix: u8) -> Option<Ipv4Cidr> {
|
||||||
|
if !(16..=32).contains(&prefix) || !address.is_private() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mask = u32::MAX.checked_shl(32 - u32::from(prefix)).unwrap_or(0);
|
||||||
|
Ipv4Cidr::new(Ipv4Addr::from(u32::from(address) & mask), prefix).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The canonical, sorted set of destinations hosts must send through the mesh:
|
||||||
|
/// the routable overlay network plus accepted advertised subnets.
|
||||||
|
pub fn mesh_cidrs(overlay: Option<(Ipv4Addr, u8)>, accepted: &[Ipv4Cidr]) -> Vec<String> {
|
||||||
|
let mut result = std::collections::BTreeSet::new();
|
||||||
|
if let Some(cidr) = overlay.and_then(|(address, prefix)| routable_overlay(address, prefix)) {
|
||||||
|
result.insert(cidr);
|
||||||
|
}
|
||||||
|
result.extend(accepted.iter().copied());
|
||||||
|
// Always spell the prefix length; host routes would otherwise print bare.
|
||||||
|
result
|
||||||
|
.into_iter()
|
||||||
|
.map(|cidr| format!("{}/{}", cidr.first_address(), cidr.network_length()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fails when the overlay overlaps an address already configured on this host,
|
||||||
|
/// such as the physical LAN or another VPN. Reads interfaces fresh, uncached.
|
||||||
|
pub fn check_local_overlap(address: Ipv4Addr, prefix: u8) -> anyhow::Result<()> {
|
||||||
|
use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};
|
||||||
|
let interfaces = NetworkInterface::show()
|
||||||
|
.map_err(|_| anyhow::anyhow!("cannot inspect existing interfaces"))?;
|
||||||
|
let conflict = interfaces
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|iface| iface.addr)
|
||||||
|
.any(|addr| match addr {
|
||||||
|
Addr::V4(v4) => v4
|
||||||
|
.netmask
|
||||||
|
.is_some_and(|mask| overlaps(address, prefix, v4.ip, u32::from(mask))),
|
||||||
|
_ => false,
|
||||||
|
});
|
||||||
|
anyhow::ensure!(
|
||||||
|
!conflict,
|
||||||
|
"overlay overlaps an existing interface; choose another subnet or stop the conflicting VPN"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
type InterfaceNetworks = Vec<(Ipv4Addr, u32)>;
|
||||||
|
type InterfaceCache = Option<(std::time::Instant, Option<InterfaceNetworks>)>;
|
||||||
|
fn local_interfaces_allow(route: &Ipv4Cidr) -> bool {
|
||||||
|
use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
static CACHE: OnceLock<Mutex<InterfaceCache>> = OnceLock::new();
|
||||||
|
let mut cache = CACHE.get_or_init(|| Mutex::new(None)).lock().unwrap();
|
||||||
|
if cache
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|(time, _)| time.elapsed().as_secs() >= 2)
|
||||||
|
{
|
||||||
|
let networks = NetworkInterface::show().ok().map(|interfaces| {
|
||||||
|
interfaces
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|iface| iface.addr)
|
||||||
|
.filter_map(|addr| match addr {
|
||||||
|
Addr::V4(v4) => Some((v4.ip, u32::from(v4.netmask?))),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
*cache = Some((std::time::Instant::now(), networks));
|
||||||
|
}
|
||||||
|
cache
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|(_, networks)| networks.as_ref())
|
||||||
|
.is_some_and(|networks| {
|
||||||
|
networks.iter().all(|(ip, mask)| {
|
||||||
|
!overlaps(route.first_address(), route.network_length(), *ip, *mask)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn rejects_defaults_public_broad_and_overlay_routes() {
|
||||||
|
let overlay = "192.168.20.4".parse().unwrap();
|
||||||
|
for text in [
|
||||||
|
"0.0.0.0/0",
|
||||||
|
"8.8.8.0/24",
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"192.168.20.0/24",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
] {
|
||||||
|
assert!(!permitted(&text.parse().unwrap(), overlay, 24), "{text}");
|
||||||
|
}
|
||||||
|
assert!(permitted(&"192.168.21.0/24".parse().unwrap(), overlay, 24));
|
||||||
|
assert!(permitted(&"10.5.0.1/32".parse().unwrap(), overlay, 24));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn mesh_cidrs_are_canonical_sorted_and_skip_unroutable_overlays() {
|
||||||
|
let accepted: Vec<Ipv4Cidr> = vec!["192.168.21.0/24".parse().unwrap()];
|
||||||
|
assert_eq!(
|
||||||
|
mesh_cidrs(Some(("10.77.0.11".parse().unwrap(), 24)), &accepted),
|
||||||
|
vec!["10.77.0.0/24", "192.168.21.0/24"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mesh_cidrs(Some(("10.77.0.11".parse().unwrap(), 32)), &[]),
|
||||||
|
vec!["10.77.0.11/32"]
|
||||||
|
);
|
||||||
|
for (address, prefix) in [("8.8.8.8", 24), ("10.0.0.1", 8)] {
|
||||||
|
assert_eq!(
|
||||||
|
mesh_cidrs(Some((address.parse().unwrap(), prefix)), &accepted),
|
||||||
|
vec!["192.168.21.0/24"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(mesh_cidrs(None, &[]).is_empty());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn detects_physical_subnet_overlap() {
|
||||||
|
let local = "10.10.8.54".parse().unwrap();
|
||||||
|
assert!(overlaps(
|
||||||
|
"10.10.0.0".parse().unwrap(),
|
||||||
|
16,
|
||||||
|
local,
|
||||||
|
0xffffff00
|
||||||
|
));
|
||||||
|
assert!(overlaps(
|
||||||
|
"10.10.8.201".parse().unwrap(),
|
||||||
|
32,
|
||||||
|
local,
|
||||||
|
0xffffff00
|
||||||
|
));
|
||||||
|
assert!(!overlaps(
|
||||||
|
"192.168.21.0".parse().unwrap(),
|
||||||
|
24,
|
||||||
|
local,
|
||||||
|
0xffffff00
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "meshport-ffi"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "staticlib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
meshport.workspace = true
|
||||||
|
meshport-easytier.workspace = true
|
||||||
|
meshport-socks.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "fmt", "registry"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
system-vpn = ["meshport/system-vpn"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
easytier = { workspace = true, features = ["proxy-packet"] }
|
||||||
|
easytier-core.workspace = true
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#ifndef MESHPORT_H
|
||||||
|
#define MESHPORT_H
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
uint32_t meshport_abi_version(void);
|
||||||
|
uint64_t meshport_context_new(void);
|
||||||
|
void meshport_context_free(uint64_t context);
|
||||||
|
/* Blocking; run off the UI thread. Owned JSON result, free exactly once. */
|
||||||
|
char *meshport_call(uint64_t context, const char *request_json);
|
||||||
|
void meshport_string_free(char *response);
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
/* Process-wide callback; keep valid until all native contexts have stopped.
|
||||||
|
* Invoked on native workers. Return VpnService.protect(fd)'s result. */
|
||||||
|
void meshport_set_android_socket_protector(bool (*callback)(int fd));
|
||||||
|
#endif
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//! Optional, process-wide capture for the lab. SDK hosts retain their subscriber.
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
io::{self, Write},
|
||||||
|
sync::{LazyLock, Mutex},
|
||||||
|
};
|
||||||
|
use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
static RECORDS: LazyLock<Mutex<(u64, VecDeque<Value>)>> =
|
||||||
|
LazyLock::new(|| Mutex::new((0, VecDeque::new())));
|
||||||
|
static ENABLED: LazyLock<bool> = LazyLock::new(|| {
|
||||||
|
let layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(|| Capture(Vec::new()))
|
||||||
|
.with_filter(tracing_subscriber::filter::filter_fn(|metadata| {
|
||||||
|
metadata
|
||||||
|
.target()
|
||||||
|
.starts_with("easytier_core::connectivity::hole_punch")
|
||||||
|
&& *metadata.level() <= tracing::Level::DEBUG
|
||||||
|
}));
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(layer)
|
||||||
|
.try_init()
|
||||||
|
.is_ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
pub fn enable() -> bool {
|
||||||
|
*ENABLED
|
||||||
|
}
|
||||||
|
pub fn snapshot() -> Vec<Value> {
|
||||||
|
RECORDS.lock().unwrap().1.iter().cloned().collect()
|
||||||
|
}
|
||||||
|
struct Capture(Vec<u8>);
|
||||||
|
impl Write for Capture {
|
||||||
|
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||||
|
let remaining = 4096usize.saturating_sub(self.0.len());
|
||||||
|
self.0
|
||||||
|
.extend_from_slice(&bytes[..bytes.len().min(remaining)]);
|
||||||
|
Ok(bytes.len())
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Capture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.0.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut records = RECORDS.lock().unwrap();
|
||||||
|
records.0 += 1;
|
||||||
|
let sequence = records.0;
|
||||||
|
records.1.push_back(
|
||||||
|
json!({"sequence": sequence, "message": String::from_utf8_lossy(&self.0).trim()}),
|
||||||
|
);
|
||||||
|
if records.1.len() > 200 {
|
||||||
|
records.1.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn capture_is_opt_in_and_excludes_other_modules() {
|
||||||
|
assert!(super::enable());
|
||||||
|
tracing::warn!(target: "unrelated_application", "private unrelated log");
|
||||||
|
tracing::warn!(target: "easytier_core::connectivity::hole_punch::test", dst_peer_id = 42, "diagnostic capture check");
|
||||||
|
let records = super::snapshot();
|
||||||
|
let text = serde_json::to_string(&records).unwrap();
|
||||||
|
assert!(text.contains("diagnostic capture check"));
|
||||||
|
assert!(text.contains("42"));
|
||||||
|
assert!(!text.contains("private unrelated log"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+862
@@ -0,0 +1,862 @@
|
|||||||
|
//! ABI v1: opaque numeric contexts and owned UTF-8 JSON responses.
|
||||||
|
//! Blocking calls must execute on a foreign worker thread/isolate.
|
||||||
|
mod diagnostics;
|
||||||
|
use meshport::Network;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
ffi::{CStr, CString, c_char},
|
||||||
|
panic::{AssertUnwindSafe, catch_unwind},
|
||||||
|
sync::{
|
||||||
|
Arc, LazyLock, Mutex,
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||||
|
/// Additive JSON operation set within ABI v1. Bump when hosts must rely on a
|
||||||
|
/// newly added operation or field; hosts reject libraries below what they need.
|
||||||
|
const CAPABILITY_REVISION: u32 = 2;
|
||||||
|
const OPERATIONS: &[&str] = &[
|
||||||
|
"abi_info",
|
||||||
|
"build_config",
|
||||||
|
"migrate_config",
|
||||||
|
"validate",
|
||||||
|
"start",
|
||||||
|
"start_bridged",
|
||||||
|
"stop",
|
||||||
|
"status",
|
||||||
|
"wait_routes",
|
||||||
|
"socks_start",
|
||||||
|
"socks_stop",
|
||||||
|
"socks_status",
|
||||||
|
"diagnostics_enable",
|
||||||
|
"probe",
|
||||||
|
"quality_sample",
|
||||||
|
"tcp_connect",
|
||||||
|
"tcp_write",
|
||||||
|
"tcp_read",
|
||||||
|
"udp_bind",
|
||||||
|
"udp_send",
|
||||||
|
"udp_receive",
|
||||||
|
"resource_close",
|
||||||
|
];
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn meshport_set_android_socket_protector(
|
||||||
|
callback: meshport_easytier::android::ProtectSocket,
|
||||||
|
) {
|
||||||
|
meshport_easytier::android::install_socket_protector(callback);
|
||||||
|
}
|
||||||
|
static CONTEXTS: LazyLock<Mutex<HashMap<u64, Arc<Context>>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
struct Context {
|
||||||
|
runtime: Runtime,
|
||||||
|
network: Mutex<Option<Arc<Network>>>,
|
||||||
|
socks: Mutex<Option<meshport_socks::Bridge>>,
|
||||||
|
tcp: tokio::sync::Mutex<HashMap<u64, meshport::TcpStream>>,
|
||||||
|
udp: tokio::sync::Mutex<HashMap<u64, meshport::UdpSocket>>,
|
||||||
|
}
|
||||||
|
impl Drop for Context {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(slot) = self.socks.get_mut()
|
||||||
|
&& let Some(mut bridge) = slot.take()
|
||||||
|
{
|
||||||
|
let _ = self.runtime.block_on(bridge.stop());
|
||||||
|
}
|
||||||
|
if let Ok(slot) = self.network.get_mut()
|
||||||
|
&& let Some(n) = slot.take()
|
||||||
|
{
|
||||||
|
self.runtime.block_on(n.stop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn meshport_abi_version() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn meshport_context_new() -> u64 {
|
||||||
|
catch_unwind(|| {
|
||||||
|
let runtime = match tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return 0,
|
||||||
|
};
|
||||||
|
let id = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||||
|
CONTEXTS.lock().unwrap().insert(
|
||||||
|
id,
|
||||||
|
Arc::new(Context {
|
||||||
|
runtime,
|
||||||
|
network: Mutex::new(None),
|
||||||
|
socks: Mutex::new(None),
|
||||||
|
tcp: tokio::sync::Mutex::new(HashMap::new()),
|
||||||
|
udp: tokio::sync::Mutex::new(HashMap::new()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
id
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn meshport_context_free(id: u64) {
|
||||||
|
// Drop outside registry lock; in-flight calls retain an Arc until completion.
|
||||||
|
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
let Some(context) = CONTEXTS.lock().unwrap().remove(&id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Stop now rather than when the last Arc drops: a `wait_routes` call
|
||||||
|
// holds one without the serial lock, and stopping is what wakes it.
|
||||||
|
if let Ok(mut network) = context.network.lock() {
|
||||||
|
let _ = stop_all(&context, &mut network);
|
||||||
|
}
|
||||||
|
drop(context);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
fn start_socks(context: &Context, n: &Arc<Network>, request: &Value) -> anyhow::Result<String> {
|
||||||
|
let mut slot = context.socks.lock().unwrap();
|
||||||
|
anyhow::ensure!(slot.is_none(), "SOCKS bridge already started");
|
||||||
|
let port = match request.get("port") {
|
||||||
|
Some(value) => u16::try_from(
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid port"))?,
|
||||||
|
)?,
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
let max_sessions = match request.get("max_sessions") {
|
||||||
|
Some(value) => usize::try_from(
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid session limit"))?,
|
||||||
|
)?,
|
||||||
|
None => 128,
|
||||||
|
};
|
||||||
|
let options = meshport_socks::Options {
|
||||||
|
listen: std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, port),
|
||||||
|
max_sessions,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let bridge = context
|
||||||
|
.runtime
|
||||||
|
.block_on(meshport_socks::Bridge::start(n.clone(), options))?;
|
||||||
|
let address = bridge.local_addr().to_string();
|
||||||
|
*slot = Some(bridge);
|
||||||
|
Ok(address)
|
||||||
|
}
|
||||||
|
fn stop_all(context: &Context, network: &mut Option<Arc<Network>>) -> anyhow::Result<()> {
|
||||||
|
let bridge = context.socks.lock().unwrap().take();
|
||||||
|
let result = match bridge {
|
||||||
|
Some(mut bridge) => context.runtime.block_on(bridge.stop()),
|
||||||
|
None => Ok(()),
|
||||||
|
};
|
||||||
|
context.tcp.blocking_lock().clear();
|
||||||
|
context.udp.blocking_lock().clear();
|
||||||
|
if let Some(n) = network.take() {
|
||||||
|
context.runtime.block_on(n.stop());
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn dispatch(context: &Context, request: Value) -> anyhow::Result<Value> {
|
||||||
|
let op = request["op"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing op"))?;
|
||||||
|
if op == "abi_info" {
|
||||||
|
return Ok(json!({"abi": 1, "revision": CAPABILITY_REVISION, "operations": OPERATIONS}));
|
||||||
|
}
|
||||||
|
// Long poll for route changes. Runs outside the serial context lock so a
|
||||||
|
// host can wait on one thread while issuing other requests on another.
|
||||||
|
if op == "wait_routes" {
|
||||||
|
let timeout_ms = request["timeout_ms"].as_u64().unwrap_or(25000);
|
||||||
|
anyhow::ensure!(
|
||||||
|
(1..=30000).contains(&timeout_ms),
|
||||||
|
"timeout_ms must be 1 to 30000"
|
||||||
|
);
|
||||||
|
let after = match request.get("after_generation") {
|
||||||
|
None | Some(Value::Null) => None,
|
||||||
|
Some(value) => Some(
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid after_generation"))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let network = context
|
||||||
|
.network
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("context lock poisoned"))?
|
||||||
|
.clone();
|
||||||
|
let Some(network) = network else {
|
||||||
|
return Ok(json!({"running": false, "changed": after.is_some(),
|
||||||
|
"route_generation": null, "mesh_cidrs": [], "subnet_routes": []}));
|
||||||
|
};
|
||||||
|
let mut watch = network.watch_mesh_routes();
|
||||||
|
let changed = context.runtime.block_on(async {
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_millis(timeout_ms),
|
||||||
|
watch.wait_for(|r| r.stopped || after.is_none_or(|a| r.generation != a)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
});
|
||||||
|
let routes = watch.borrow().clone();
|
||||||
|
return Ok(json!({"running": !routes.stopped,
|
||||||
|
"changed": changed && after.is_some_and(|a| a != routes.generation),
|
||||||
|
"route_generation": routes.generation, "mesh_cidrs": routes.cidrs,
|
||||||
|
"subnet_routes": routes.subnet_routes}));
|
||||||
|
}
|
||||||
|
if op == "diagnostics_enable" {
|
||||||
|
return Ok(json!({"enabled": diagnostics::enable(), "scope": "process"}));
|
||||||
|
}
|
||||||
|
if op == "build_config" {
|
||||||
|
return Ok(json!({"toml": meshport::build_config(&request["profile"])?}));
|
||||||
|
}
|
||||||
|
if op == "migrate_config" {
|
||||||
|
let input = request["toml"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing toml"))?;
|
||||||
|
let (toml, changed) = meshport::migrate_config(input)?;
|
||||||
|
return Ok(json!({"toml": toml, "changed": changed}));
|
||||||
|
}
|
||||||
|
if op == "validate" {
|
||||||
|
use meshport_easytier::ConfigLoader;
|
||||||
|
let input = request["toml"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing toml"))?;
|
||||||
|
let config = meshport::validate_config(input)?;
|
||||||
|
if request["check_local_overlap"].as_bool() == Some(true) {
|
||||||
|
meshport::check_local_overlap(&config)?;
|
||||||
|
}
|
||||||
|
let flags = config.get_flags();
|
||||||
|
let mut result = json!({"valid": true, "runtime_mode": if flags.no_tun {"application"} else {"system_vpn"},
|
||||||
|
"overlay": meshport::overlay_projection(&config)});
|
||||||
|
// Only return credentials when the local caller explicitly requests a
|
||||||
|
// form projection. Ordinary validation remains free of credentials.
|
||||||
|
if request["include_profile"].as_bool() == Some(true) {
|
||||||
|
let identity = config.get_network_identity();
|
||||||
|
let optimization = meshport_easytier::optimization_config(input)?;
|
||||||
|
result["profile"] = json!({
|
||||||
|
"network_name": identity.network_name,
|
||||||
|
"network_secret": identity.network_secret.unwrap_or_default(),
|
||||||
|
"hostname": config.get_hostname(),
|
||||||
|
"ipv4": config.get_ipv4().map(|ip| ip.to_string()).unwrap_or_default(),
|
||||||
|
"dhcp": config.get_dhcp(),
|
||||||
|
"peers": config.get_peers().iter().map(|peer| peer.uri.to_string()).collect::<Vec<_>>(),
|
||||||
|
"enable_ipv6": flags.enable_ipv6,
|
||||||
|
"latency_first": flags.latency_first,
|
||||||
|
"optimization": optimization,
|
||||||
|
"listeners": config.get_listener_uris().iter().map(|uri| uri.to_string()).collect::<Vec<_>>(),
|
||||||
|
"stun_servers": config.get_stun_servers(),
|
||||||
|
"stun_servers_v6": config.get_stun_servers_v6(),
|
||||||
|
"flags": {
|
||||||
|
"default_protocol": flags.default_protocol,
|
||||||
|
"disable_p2p": flags.disable_p2p,
|
||||||
|
"disable_tcp_hole_punching": flags.disable_tcp_hole_punching,
|
||||||
|
"disable_udp_hole_punching": flags.disable_udp_hole_punching,
|
||||||
|
"disable_sym_hole_punching": flags.disable_sym_hole_punching,
|
||||||
|
"p2p_only": flags.p2p_only,
|
||||||
|
"lazy_p2p": flags.lazy_p2p,
|
||||||
|
"need_p2p": flags.need_p2p,
|
||||||
|
"private_mode": flags.private_mode,
|
||||||
|
"foreign_relay_bps_limit": flags.foreign_relay_bps_limit.to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
let mut network = context
|
||||||
|
.network
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("context lock poisoned"))?;
|
||||||
|
match op {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
"attach_tun" => {
|
||||||
|
let fd = i32::try_from(
|
||||||
|
request["fd"]
|
||||||
|
.as_i64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing fd"))?,
|
||||||
|
)?;
|
||||||
|
network
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("not started"))?
|
||||||
|
.attach_tun_fd(fd)?;
|
||||||
|
Ok(json!({"attached": true}))
|
||||||
|
}
|
||||||
|
"start" => {
|
||||||
|
anyhow::ensure!(
|
||||||
|
network.is_none(),
|
||||||
|
"already started; stop before applying a new configuration"
|
||||||
|
);
|
||||||
|
let config = request["toml"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing toml"))?;
|
||||||
|
if request["check_local_overlap"].as_bool() == Some(true) {
|
||||||
|
meshport::check_local_overlap(&meshport::validate_config(config)?)?;
|
||||||
|
}
|
||||||
|
*network = Some(Arc::new(context.runtime.block_on(Network::start(config))?));
|
||||||
|
Ok(json!({"engine_started": true}))
|
||||||
|
}
|
||||||
|
// Application-mode network plus SOCKS bridge as one step, for hosts that
|
||||||
|
// route traffic into the mesh. Either both run or neither does.
|
||||||
|
"start_bridged" => {
|
||||||
|
use meshport_easytier::ConfigLoader;
|
||||||
|
anyhow::ensure!(
|
||||||
|
network.is_none(),
|
||||||
|
"already started; stop before applying a new configuration"
|
||||||
|
);
|
||||||
|
let input = request["toml"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing toml"))?;
|
||||||
|
let config = meshport::validate_config(input)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
config.get_flags().no_tun,
|
||||||
|
"bridged routing requires application-mode MeshPort"
|
||||||
|
);
|
||||||
|
let overlay = meshport::overlay_projection(&config);
|
||||||
|
anyhow::ensure!(
|
||||||
|
!overlay.is_null() || request["require_static_overlay"].as_bool() != Some(true),
|
||||||
|
"static mesh IPv4 required for bridged routing"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
overlay.is_null() || overlay["routable"] == true,
|
||||||
|
"bridged routing requires a private IPv4 overlay with prefix /16 through /32"
|
||||||
|
);
|
||||||
|
if request["check_local_overlap"].as_bool() == Some(true) {
|
||||||
|
meshport::check_local_overlap(&config)?;
|
||||||
|
}
|
||||||
|
let n = Arc::new(context.runtime.block_on(Network::start(input))?);
|
||||||
|
*network = Some(n.clone());
|
||||||
|
let listen = match start_socks(context, &n, &request) {
|
||||||
|
Ok(listen) => listen,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = stop_all(context, &mut network);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let routes = n.watch_mesh_routes().borrow().clone();
|
||||||
|
Ok(
|
||||||
|
json!({"engine_started": true, "listen": listen, "overlay": overlay,
|
||||||
|
"mesh_cidrs": routes.cidrs, "route_generation": routes.generation}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"stop" => {
|
||||||
|
stop_all(context, &mut network)?;
|
||||||
|
Ok(json!({"stopped": true}))
|
||||||
|
}
|
||||||
|
"socks_start" => {
|
||||||
|
let n = network
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("network is stopped"))?;
|
||||||
|
let address = start_socks(context, n, &request)?;
|
||||||
|
Ok(
|
||||||
|
json!({"running": true, "listen": address, "tcp": true, "udp": true, "ipv4_only": true, "dns_resolution": false}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"socks_stop" => {
|
||||||
|
if let Some(mut bridge) = context.socks.lock().unwrap().take() {
|
||||||
|
context.runtime.block_on(bridge.stop())?;
|
||||||
|
}
|
||||||
|
Ok(json!({"stopped": true}))
|
||||||
|
}
|
||||||
|
"socks_status" => {
|
||||||
|
let slot = context.socks.lock().unwrap();
|
||||||
|
Ok(match slot.as_ref() {
|
||||||
|
Some(bridge) => {
|
||||||
|
let s = bridge.snapshot();
|
||||||
|
json!({"running": s.running, "listen": bridge.local_addr().to_string(),
|
||||||
|
"active_sessions": s.active_sessions, "accepted_sessions": s.accepted_sessions,
|
||||||
|
"completed_sessions": s.completed_sessions, "failed_sessions": s.failed_sessions,
|
||||||
|
"refused_at_capacity": s.refused_at_capacity, "dropped_udp_packets": s.dropped_udp_packets})
|
||||||
|
}
|
||||||
|
None => json!({"running": false, "active_sessions": 0}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"status" => match network.as_ref() {
|
||||||
|
Some(n) => {
|
||||||
|
let mut snapshot = context.runtime.block_on(n.snapshot());
|
||||||
|
let traces = diagnostics::snapshot()
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut trace| {
|
||||||
|
if let Some(message) = trace["message"].as_str() {
|
||||||
|
trace["message"] = json!(n.redact_diagnostic(message));
|
||||||
|
}
|
||||||
|
trace
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
snapshot["diagnostic_traces"] = json!(traces);
|
||||||
|
let engine_running = snapshot["state"] == "Running";
|
||||||
|
let bridge_running = context
|
||||||
|
.socks
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|b| b.snapshot().running);
|
||||||
|
// mesh_routable: traffic a host routes to mesh_cidrs via the
|
||||||
|
// bridge can currently be delivered.
|
||||||
|
snapshot["health"] = json!({"engine_running": engine_running,
|
||||||
|
"bridge_running": bridge_running,
|
||||||
|
"mesh_routable": engine_running && bridge_running});
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
None => Ok(
|
||||||
|
json!({"state": "Stopped", "peer_count": 0, "application_only": true,
|
||||||
|
"health": {"engine_running": false, "bridge_running": false, "mesh_routable": false}}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"quality_sample" => {
|
||||||
|
let network = network
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("network is stopped"))?;
|
||||||
|
let id = request["resource"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing resource"))?;
|
||||||
|
let target = request["target"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing target"))?
|
||||||
|
.parse::<std::net::SocketAddr>()?;
|
||||||
|
anyhow::ensure!(target.is_ipv4(), "IPv4 required");
|
||||||
|
let sequence = request["sequence"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing sequence"))?;
|
||||||
|
let timeout = request["timeout_ms"].as_u64().unwrap_or(1000);
|
||||||
|
anyhow::ensure!(
|
||||||
|
(100..=5000).contains(&timeout),
|
||||||
|
"quality timeout must be 100-5000 ms"
|
||||||
|
);
|
||||||
|
context.runtime.block_on(async {
|
||||||
|
let sockets = context.udp.lock().await;
|
||||||
|
let socket = sockets
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid UDP resource"))?;
|
||||||
|
network
|
||||||
|
.sample_udp_quality(socket, target, sequence, Duration::from_millis(timeout))
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"tcp_connect" | "udp_bind" => {
|
||||||
|
let n = network
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("network is stopped"))?;
|
||||||
|
let id = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if op == "tcp_connect" {
|
||||||
|
let target = request["target"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing target"))?
|
||||||
|
.parse()?;
|
||||||
|
let stream = context
|
||||||
|
.runtime
|
||||||
|
.block_on(n.connect_tcp(target, Duration::from_secs(5)))?;
|
||||||
|
context.tcp.blocking_lock().insert(id, stream);
|
||||||
|
} else {
|
||||||
|
let port = request["port"].as_u64().unwrap_or(0);
|
||||||
|
anyhow::ensure!(port <= 65535, "invalid UDP port");
|
||||||
|
let socket = context
|
||||||
|
.runtime
|
||||||
|
.block_on(n.bind_udp(port as u16, Duration::from_secs(5)))?;
|
||||||
|
context.udp.blocking_lock().insert(id, socket);
|
||||||
|
}
|
||||||
|
Ok(json!({"resource": id}))
|
||||||
|
}
|
||||||
|
"resource_close" => {
|
||||||
|
let id = request["resource"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing resource"))?;
|
||||||
|
let tcp = context.tcp.blocking_lock().remove(&id).is_some();
|
||||||
|
let udp = context.udp.blocking_lock().remove(&id).is_some();
|
||||||
|
anyhow::ensure!(tcp || udp, "invalid resource");
|
||||||
|
Ok(json!({"closed": true}))
|
||||||
|
}
|
||||||
|
"tcp_read" | "tcp_write" | "udp_send" | "udp_receive" => {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
anyhow::ensure!(network.is_some(), "network is stopped");
|
||||||
|
let id = request["resource"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing resource"))?;
|
||||||
|
let timeout = request["timeout_ms"].as_u64().unwrap_or(5000);
|
||||||
|
anyhow::ensure!((1..=30000).contains(&timeout), "invalid timeout");
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
if op.ends_with("write") || op == "udp_send" {
|
||||||
|
let data = request["bytes"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing bytes array"))?;
|
||||||
|
anyhow::ensure!(data.len() <= 65507, "payload too large");
|
||||||
|
for b in data {
|
||||||
|
let b = b
|
||||||
|
.as_u64()
|
||||||
|
.filter(|b| *b <= 255)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid byte"))?;
|
||||||
|
bytes.push(b as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.runtime.block_on(async {
|
||||||
|
tokio::time::timeout(Duration::from_millis(timeout), async {
|
||||||
|
if op.starts_with("tcp") {
|
||||||
|
let mut streams = context.tcp.lock().await;
|
||||||
|
let stream = streams
|
||||||
|
.get_mut(&id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid TCP resource"))?;
|
||||||
|
if op == "tcp_write" {
|
||||||
|
stream.write_all(&bytes).await?;
|
||||||
|
Ok(json!({"written": bytes.len()}))
|
||||||
|
} else {
|
||||||
|
let max = request["max_bytes"].as_u64().unwrap_or(16384);
|
||||||
|
anyhow::ensure!((1..=65535).contains(&max), "invalid read size");
|
||||||
|
let mut buffer = vec![0; max as usize];
|
||||||
|
let size = stream.read(&mut buffer).await?;
|
||||||
|
buffer.truncate(size);
|
||||||
|
Ok(json!({"bytes": buffer, "eof": size == 0}))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let sockets = context.udp.lock().await;
|
||||||
|
let socket = sockets
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid UDP resource"))?;
|
||||||
|
if op == "udp_send" {
|
||||||
|
let addr = request["target"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing target"))?
|
||||||
|
.parse()?;
|
||||||
|
let written = socket.send_to(&bytes, addr).await?;
|
||||||
|
Ok(json!({"written": written}))
|
||||||
|
} else {
|
||||||
|
let mut buffer = vec![0; 65535];
|
||||||
|
let (size, source) = socket.recv_from(&mut buffer).await?;
|
||||||
|
buffer.truncate(size);
|
||||||
|
Ok(json!({"bytes": buffer, "source": source.to_string()}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
"probe" => {
|
||||||
|
let n = network
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("network is stopped"))?;
|
||||||
|
let target = request["target"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing target"))?
|
||||||
|
.parse()?;
|
||||||
|
let protocol = request["protocol"].as_str().unwrap_or("TCP");
|
||||||
|
anyhow::ensure!(
|
||||||
|
["TCP", "UDP"].contains(&protocol),
|
||||||
|
"protocol must be TCP or UDP"
|
||||||
|
);
|
||||||
|
let timeout_ms = request["timeout_ms"].as_u64().unwrap_or(5000);
|
||||||
|
anyhow::ensure!(
|
||||||
|
(1..=30000).contains(&timeout_ms),
|
||||||
|
"timeout must be 1..30000 ms"
|
||||||
|
);
|
||||||
|
context.runtime.block_on(n.probe(
|
||||||
|
target,
|
||||||
|
protocol == "UDP",
|
||||||
|
request["echo"].as_bool().unwrap_or(false),
|
||||||
|
Duration::from_millis(timeout_ms),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!("unknown operation"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// # Safety
|
||||||
|
/// request must be a valid NUL-terminated UTF-8 C string for the duration of the call.
|
||||||
|
/// Return is owned by MeshPort; release exactly once with meshport_string_free.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn meshport_call(id: u64, request: *const c_char) -> *mut c_char {
|
||||||
|
let response = catch_unwind(AssertUnwindSafe(|| -> anyhow::Result<Value> {
|
||||||
|
anyhow::ensure!(!request.is_null(), "request is null");
|
||||||
|
let text = unsafe { CStr::from_ptr(request) }.to_str()?;
|
||||||
|
anyhow::ensure!(text.len() <= 131072, "request exceeds 128 KiB");
|
||||||
|
let parsed: Value =
|
||||||
|
serde_json::from_str(text).map_err(|_| anyhow::anyhow!("invalid JSON request"))?;
|
||||||
|
let context = CONTEXTS
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid context"))?;
|
||||||
|
dispatch(&context, parsed)
|
||||||
|
}));
|
||||||
|
let value = match response {
|
||||||
|
Ok(Ok(v)) => json!({"ok": true, "data": v}),
|
||||||
|
Ok(Err(e)) => json!({"ok": false, "error": e.to_string()}),
|
||||||
|
Err(_) => json!({"ok": false, "error": "internal panic"}),
|
||||||
|
};
|
||||||
|
CString::new(value.to_string()).unwrap().into_raw()
|
||||||
|
}
|
||||||
|
/// # Safety
|
||||||
|
/// ptr must be null or an unfreed pointer returned by meshport_call.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn meshport_string_free(ptr: *mut c_char) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
drop(unsafe { CString::from_raw(ptr) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn call(id: u64, s: &str) -> Value {
|
||||||
|
let q = CString::new(s).unwrap();
|
||||||
|
unsafe {
|
||||||
|
let p = meshport_call(id, q.as_ptr());
|
||||||
|
let result = serde_json::from_str(CStr::from_ptr(p).to_str().unwrap()).unwrap();
|
||||||
|
meshport_string_free(p);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn abi_invalid_handle_and_lifecycle() {
|
||||||
|
let id = meshport_context_new();
|
||||||
|
assert_ne!(id, 0);
|
||||||
|
assert_eq!(call(id, r#"{"op":"status"}"#)["data"]["state"], "Stopped");
|
||||||
|
assert_eq!(
|
||||||
|
call(id, r#"{"op":"probe","target":"10.0.0.2:1234"}"#)["ok"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
meshport_context_free(id);
|
||||||
|
meshport_context_free(id);
|
||||||
|
assert_eq!(call(id, r#"{"op":"status"}"#)["ok"], false);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn validation_only_projects_credentials_when_requested() {
|
||||||
|
let id = meshport_context_new();
|
||||||
|
let config = "hostname = 'saved-node'\nipv4 = '10.79.0.11/24'\nlisteners = []\n[[peer]]\nuri = 'tcp://127.0.0.1:1234'\n[network_identity]\nnetwork_name = 'saved-network'\nnetwork_secret = 'test-only-secret'\n";
|
||||||
|
let ordinary = call(id, &json!({"op": "validate", "toml": config}).to_string());
|
||||||
|
assert_eq!(ordinary["ok"], true);
|
||||||
|
assert!(!ordinary.to_string().contains("test-only-secret"));
|
||||||
|
let projected = call(
|
||||||
|
id,
|
||||||
|
&json!({"op": "validate", "toml": config, "include_profile": true}).to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(projected["ok"], true);
|
||||||
|
assert_eq!(
|
||||||
|
projected["data"]["profile"]["flags"]["foreign_relay_bps_limit"],
|
||||||
|
"18446744073709551615"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
projected["data"]["profile"]["network_secret"],
|
||||||
|
"test-only-secret"
|
||||||
|
);
|
||||||
|
assert_eq!(projected["data"]["profile"]["ipv4"], "10.79.0.11/24");
|
||||||
|
assert_eq!(
|
||||||
|
projected["data"]["profile"]["peers"][0],
|
||||||
|
"tcp://127.0.0.1:1234"
|
||||||
|
);
|
||||||
|
meshport_context_free(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_build_and_migration_are_sdk_operations() {
|
||||||
|
let id = meshport_context_new();
|
||||||
|
let built = call(
|
||||||
|
id,
|
||||||
|
&json!({"op": "build_config", "profile": {"runtime_mode": "unified_vpn",
|
||||||
|
"ipv4": "10.79.0.11/24", "network_name": "n", "network_secret": "s"}})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(built["ok"], true);
|
||||||
|
let toml = built["data"]["toml"].as_str().unwrap();
|
||||||
|
let validated = call(id, &json!({"op": "validate", "toml": toml}).to_string());
|
||||||
|
assert_eq!(validated["data"]["runtime_mode"], "application");
|
||||||
|
assert_eq!(validated["data"]["overlay"]["network"], "10.79.0.0/24");
|
||||||
|
let legacy = format!("{toml}\nsubnet_routes = ['192.168.21.0/24']\n");
|
||||||
|
let migrated = call(
|
||||||
|
id,
|
||||||
|
&json!({"op": "migrate_config", "toml": legacy}).to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(migrated["data"]["changed"], true);
|
||||||
|
let migrated = migrated["data"]["toml"].as_str().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
call(id, &json!({"op": "validate", "toml": migrated}).to_string())["ok"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
meshport_context_free(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridged_start_is_atomic_and_reports_routes_and_health() {
|
||||||
|
let id = meshport_context_new();
|
||||||
|
let base = "listeners = []\nstun_servers = []\nstun_servers_v6 = []\n[network_identity]\nnetwork_name = 'bridged-ffi-test'\nnetwork_secret = 'test-only'\n";
|
||||||
|
let system = format!("ipv4 = '10.253.84.11/24'\n{base}[runtime]\nmode = 'system_vpn'\n");
|
||||||
|
let wide = format!("ipv4 = '10.253.84.11/8'\n{base}");
|
||||||
|
let dhcp = format!("dhcp = true\n{base}");
|
||||||
|
for (config, static_required) in [(system, false), (wide, false), (dhcp, true)] {
|
||||||
|
let refused = call(
|
||||||
|
id,
|
||||||
|
&json!({"op": "start_bridged", "toml": config,
|
||||||
|
"require_static_overlay": static_required})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(refused["ok"], false, "{config}");
|
||||||
|
assert_eq!(call(id, r#"{"op":"status"}"#)["data"]["state"], "Stopped");
|
||||||
|
}
|
||||||
|
// Bridge failure (invalid session limit) rolls the network back.
|
||||||
|
let config = format!("ipv4 = '10.253.84.11/24'\n{base}");
|
||||||
|
let failed = call(
|
||||||
|
id,
|
||||||
|
&json!({"op": "start_bridged", "toml": config, "max_sessions": 0}).to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(failed["ok"], false);
|
||||||
|
assert_eq!(call(id, r#"{"op":"status"}"#)["data"]["state"], "Stopped");
|
||||||
|
|
||||||
|
let started = call(
|
||||||
|
id,
|
||||||
|
&json!({"op": "start_bridged", "toml": config}).to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(started["ok"], true, "{started}");
|
||||||
|
assert_eq!(started["data"]["mesh_cidrs"], json!(["10.253.84.0/24"]));
|
||||||
|
assert!(started["data"]["route_generation"].as_u64().unwrap() >= 1);
|
||||||
|
assert!(std::net::TcpStream::connect(started["data"]["listen"].as_str().unwrap()).is_ok());
|
||||||
|
let status = call(id, r#"{"op":"status"}"#)["data"].clone();
|
||||||
|
assert_eq!(status["health"]["mesh_routable"], true);
|
||||||
|
assert_eq!(
|
||||||
|
status["platform_network"]["mesh_cidrs"],
|
||||||
|
json!(["10.253.84.0/24"])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status["platform_network"]["overlay"]["address"],
|
||||||
|
"10.253.84.11"
|
||||||
|
);
|
||||||
|
let generation = started["data"]["route_generation"].as_u64().unwrap();
|
||||||
|
let idle = call(
|
||||||
|
id,
|
||||||
|
&json!({"op": "wait_routes", "after_generation": generation, "timeout_ms": 200})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(idle["data"]["changed"], false, "{idle}");
|
||||||
|
assert_eq!(idle["data"]["running"], true);
|
||||||
|
assert_eq!(call(id, r#"{"op":"socks_stop"}"#)["ok"], true);
|
||||||
|
let status = call(id, r#"{"op":"status"}"#)["data"].clone();
|
||||||
|
assert_eq!(status["health"]["engine_running"], true);
|
||||||
|
assert_eq!(status["health"]["mesh_routable"], false);
|
||||||
|
// A waiter does not hold the context: status still answers, and stop
|
||||||
|
// wakes it with empty routes well before its timeout.
|
||||||
|
let waiter = std::thread::spawn(move || {
|
||||||
|
let at = std::time::Instant::now();
|
||||||
|
let reply = call(
|
||||||
|
id,
|
||||||
|
&json!({"op": "wait_routes", "after_generation": generation, "timeout_ms": 20000})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
(reply, at.elapsed())
|
||||||
|
});
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
assert_eq!(call(id, r#"{"op":"status"}"#)["data"]["state"], "Running");
|
||||||
|
assert_eq!(call(id, r#"{"op":"stop"}"#)["ok"], true);
|
||||||
|
let (woken, waited) = waiter.join().unwrap();
|
||||||
|
assert!(waited < Duration::from_secs(10), "{waited:?}");
|
||||||
|
assert_eq!(woken["data"]["running"], false, "{woken}");
|
||||||
|
assert_eq!(woken["data"]["changed"], true);
|
||||||
|
assert_eq!(woken["data"]["mesh_cidrs"], json!([]));
|
||||||
|
assert_eq!(
|
||||||
|
call(id, r#"{"op":"wait_routes","timeout_ms":0}"#)["ok"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
let info = call(id, r#"{"op":"abi_info"}"#)["data"].clone();
|
||||||
|
assert_eq!(info["abi"], 1);
|
||||||
|
assert!(info["revision"].as_u64().unwrap() >= 2);
|
||||||
|
assert!(
|
||||||
|
info["operations"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|o| o == "wait_routes")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(id, r#"{"op":"status"}"#)["data"]["health"]["mesh_routable"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
// Freeing without stop also stops the network and wakes a waiter.
|
||||||
|
let started = call(
|
||||||
|
id,
|
||||||
|
&json!({"op": "start_bridged", "toml": config}).to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(started["ok"], true, "{started}");
|
||||||
|
let generation = started["data"]["route_generation"].as_u64().unwrap();
|
||||||
|
let waiter = std::thread::spawn(move || {
|
||||||
|
let at = std::time::Instant::now();
|
||||||
|
let reply = call(
|
||||||
|
id,
|
||||||
|
&json!({"op": "wait_routes", "after_generation": generation, "timeout_ms": 20000})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
(reply, at.elapsed())
|
||||||
|
});
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
meshport_context_free(id);
|
||||||
|
let (woken, waited) = waiter.join().unwrap();
|
||||||
|
assert!(waited < Duration::from_secs(10), "{waited:?}");
|
||||||
|
assert_eq!(woken["data"]["running"], false, "{woken}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socks_control_lifecycle_and_context_cleanup() {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
let id = meshport_context_new();
|
||||||
|
assert_eq!(call(id, r#"{"op":"socks_start"}"#)["ok"], false);
|
||||||
|
let config = "ipv4 = '10.253.83.11/24'\nlisteners = []\nstun_servers = []\nstun_servers_v6 = []\n[network_identity]\nnetwork_name = 'socks-ffi-test'\nnetwork_secret = 'test-only'\n";
|
||||||
|
assert_eq!(
|
||||||
|
call(id, &json!({"op":"start", "toml":config}).to_string())["ok"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(id, r#"{"op":"socks_start","port":65536}"#)["ok"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(id, r#"{"op":"socks_start","max_sessions":0}"#)["ok"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
let started = call(id, r#"{"op":"socks_start"}"#);
|
||||||
|
assert_eq!(started["ok"], true);
|
||||||
|
let address = started["data"]["listen"].as_str().unwrap();
|
||||||
|
let mut control = std::net::TcpStream::connect(address).unwrap();
|
||||||
|
control
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||||
|
.unwrap();
|
||||||
|
control.write_all(&[5, 1, 0]).unwrap();
|
||||||
|
let mut reply = [0; 2];
|
||||||
|
control.read_exact(&mut reply).unwrap();
|
||||||
|
assert_eq!(reply, [5, 0]);
|
||||||
|
// A native session waiting for its request does not hold the ABI lock.
|
||||||
|
assert_eq!(
|
||||||
|
call(id, r#"{"op":"socks_status"}"#)["data"]["active_sessions"],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(call(id, r#"{"op":"status"}"#)["data"]["state"], "Running");
|
||||||
|
assert_eq!(call(id, r#"{"op":"socks_start"}"#)["ok"], false);
|
||||||
|
assert_eq!(call(id, r#"{"op":"socks_stop"}"#)["ok"], true);
|
||||||
|
assert_eq!(control.read(&mut [0]).unwrap(), 0);
|
||||||
|
assert!(std::net::TcpStream::connect(address).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
call(id, r#"{"op":"socks_status"}"#)["data"]["running"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(call(id, r#"{"op":"status"}"#)["data"]["state"], "Running");
|
||||||
|
assert_eq!(call(id, r#"{"op":"socks_start"}"#)["ok"], true);
|
||||||
|
assert_eq!(call(id, r#"{"op":"stop"}"#)["ok"], true);
|
||||||
|
assert_eq!(
|
||||||
|
call(id, r#"{"op":"socks_status"}"#)["data"]["running"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
call(id, &json!({"op":"start", "toml":config}).to_string())["ok"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
let restarted = call(id, r#"{"op":"socks_start"}"#);
|
||||||
|
let address = restarted["data"]["listen"].as_str().unwrap();
|
||||||
|
let mut control = std::net::TcpStream::connect(address).unwrap();
|
||||||
|
control
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||||
|
.unwrap();
|
||||||
|
control.write_all(&[5, 1, 0]).unwrap();
|
||||||
|
control.read_exact(&mut reply).unwrap();
|
||||||
|
meshport_context_free(id);
|
||||||
|
assert_eq!(control.read(&mut [0]).unwrap(), 0);
|
||||||
|
// No lingering listener after context destruction.
|
||||||
|
std::net::TcpListener::bind(address).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use easytier::instance::factory::create_native_instance;
|
||||||
|
use easytier_core::config::toml::TomlConfig;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::ffi::{CStr, CString};
|
||||||
|
fn call(id: u64, request: Value) -> Value {
|
||||||
|
let input = CString::new(request.to_string()).unwrap();
|
||||||
|
unsafe {
|
||||||
|
let reply = meshport_ffi::meshport_call(id, input.as_ptr());
|
||||||
|
let result = serde_json::from_slice(CStr::from_ptr(reply).to_bytes()).unwrap();
|
||||||
|
meshport_ffi::meshport_string_free(reply);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ffi_udp_through_adjacent_mapped_subnet() {
|
||||||
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
let _entered = runtime.enter();
|
||||||
|
let setup = async {
|
||||||
|
let port = UdpSocket::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.local_addr()
|
||||||
|
.unwrap()
|
||||||
|
.port();
|
||||||
|
let identity =
|
||||||
|
"[network_identity]\nnetwork_name='unified-udp-fixture'\nnetwork_secret='test-only'\n";
|
||||||
|
let server_config = TomlConfig::new_from_str(&format!(
|
||||||
|
"ipv4='10.253.88.2/25'\nlisteners=['udp://127.0.0.1:{port}']\nstun_servers=[]\nstun_servers_v6=[]\n[[proxy_network]]\ncidr='127.0.0.0/25'\nmapped_cidr='10.253.88.128/25'\n{identity}[flags]\nno_tun=true\nuse_smoltcp=true"
|
||||||
|
)).unwrap();
|
||||||
|
let server = create_native_instance(server_config).unwrap();
|
||||||
|
server.start().await.unwrap();
|
||||||
|
let client = format!(
|
||||||
|
"ipv4='10.253.88.11/25'\nlisteners=[]\nstun_servers=[]\nstun_servers_v6=[]\n[[peer]]\nuri='udp://127.0.0.1:{port}'\n{identity}[flags]\nno_tun=true\nuse_smoltcp=true"
|
||||||
|
);
|
||||||
|
(server, client)
|
||||||
|
};
|
||||||
|
let (server, client) = runtime.block_on(setup);
|
||||||
|
drop(_entered);
|
||||||
|
let id = meshport_ffi::meshport_context_new();
|
||||||
|
assert_eq!(call(id, json!({"op":"start", "toml":client}))["ok"], true);
|
||||||
|
let deadline = std::time::Instant::now();
|
||||||
|
loop {
|
||||||
|
if call(id, json!({"op":"status"}))["data"]["platform_network"]["subnet_routes"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|v| v.iter().any(|r| r == "10.253.88.128/25"))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(deadline.elapsed() < Duration::from_secs(15));
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
let (target, echo): (std::net::SocketAddr, _) = runtime.block_on(async {
|
||||||
|
let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let target = format!("10.253.88.129:{}", udp.local_addr().unwrap().port())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let echo = tokio::spawn(async move {
|
||||||
|
let mut buffer = [0; 128];
|
||||||
|
let (n, source) = udp.recv_from(&mut buffer).await.unwrap();
|
||||||
|
udp.send_to(&buffer[..n], source).await.unwrap();
|
||||||
|
});
|
||||||
|
(target, echo)
|
||||||
|
});
|
||||||
|
let result = call(
|
||||||
|
id,
|
||||||
|
json!({"op":"probe", "target":target.to_string(), "protocol":"UDP", "echo":true, "timeout_ms":3000}),
|
||||||
|
);
|
||||||
|
|
||||||
|
echo.abort();
|
||||||
|
call(id, json!({"op":"stop"}));
|
||||||
|
meshport_ffi::meshport_context_free(id);
|
||||||
|
runtime.block_on(server.stop());
|
||||||
|
assert_eq!(result["ok"], true, "FFI UDP failed: {result}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "meshport-netstack"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
easytier-core.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! Reuse EasyTier's instance-scoped smoltcp plane rather than creating a second stack.
|
||||||
|
use anyhow::{Result, ensure};
|
||||||
|
pub use easytier_core::gateway::{
|
||||||
|
DataPlaneTcpListener as TcpListener, DataPlaneTcpStream as TcpStream,
|
||||||
|
DataPlaneUdpSocket as UdpSocket,
|
||||||
|
};
|
||||||
|
use std::{net::SocketAddr, time::Duration};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
pub async fn tcp_echo(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
payload: &[u8],
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
tokio::time::timeout(timeout, async {
|
||||||
|
stream.write_all(payload).await?;
|
||||||
|
let mut received = vec![0; payload.len()];
|
||||||
|
stream.read_exact(&mut received).await?;
|
||||||
|
ensure!(received == payload, "TCP echo data mismatch");
|
||||||
|
Ok(received)
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
pub async fn udp_echo(
|
||||||
|
socket: &UdpSocket,
|
||||||
|
target: SocketAddr,
|
||||||
|
payload: &[u8],
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
ensure!(
|
||||||
|
payload.len() <= 65507,
|
||||||
|
"UDP payload exceeds IPv4 datagram limit"
|
||||||
|
);
|
||||||
|
tokio::time::timeout(timeout, async {
|
||||||
|
socket.send_to(payload, target).await?;
|
||||||
|
let mut buffer = vec![0; 65535];
|
||||||
|
loop {
|
||||||
|
let (len, source) = socket.recv_from(&mut buffer).await?;
|
||||||
|
if source != target {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ensure!(&buffer[..len] == payload, "UDP echo data mismatch");
|
||||||
|
return Ok(buffer[..len].to_vec());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "meshport-socks"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
meshport.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
meshport-easytier.workspace = true
|
||||||
|
easytier = { workspace = true, features = ["smoltcp", "proxy-packet"] }
|
||||||
|
easytier-core.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["signal", "process"] }
|
||||||
|
serde_json.workspace = true
|
||||||
|
futures = "0.3"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
smoltcp = { version = "0.14", default-features = false }
|
||||||
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
bytes = "1"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! Run with an application-mode TOML file; never prints its credentials.
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use meshport::Network;
|
||||||
|
use meshport_socks::{Bridge, Options};
|
||||||
|
use std::{
|
||||||
|
net::{Ipv4Addr, SocketAddrV4},
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let mut arguments = std::env::args().skip(1);
|
||||||
|
let path = arguments
|
||||||
|
.next()
|
||||||
|
.context("usage: local_bridge CONFIG.toml [PORT]")?;
|
||||||
|
let port = arguments
|
||||||
|
.next()
|
||||||
|
.map(|s| s.parse::<u16>())
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(0);
|
||||||
|
anyhow::ensure!(arguments.next().is_none(), "too many arguments");
|
||||||
|
let config = std::fs::read_to_string(path).context("cannot read configuration")?;
|
||||||
|
let network = Arc::new(Network::start(&config).await?);
|
||||||
|
let result = async {
|
||||||
|
let mut bridge = Bridge::start(
|
||||||
|
network.clone(),
|
||||||
|
Options {
|
||||||
|
listen: SocketAddrV4::new(Ipv4Addr::LOCALHOST, port),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
println!("MeshPort SOCKS5 IPv4 TCP/UDP: {}", bridge.local_addr());
|
||||||
|
println!("Waiting for Ctrl-C; engine startup does not guarantee remote reachability.");
|
||||||
|
let signal = tokio::signal::ctrl_c().await;
|
||||||
|
bridge.stop().await?;
|
||||||
|
signal?;
|
||||||
|
Ok::<_, anyhow::Error>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
network.stop().await;
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
//! Local IPv4 SOCKS5 bridge to the MeshPort application data plane.
|
||||||
|
//! No DNS or ordinary internet fallback. This is a restricted internal bridge,
|
||||||
|
//! not a public SOCKS service (no GSSAPI, password authentication, or BIND).
|
||||||
|
mod protocol;
|
||||||
|
|
||||||
|
use anyhow::{Result, ensure};
|
||||||
|
use meshport::Network;
|
||||||
|
use protocol::*;
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU64, AtomicUsize, Ordering},
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt, copy, split},
|
||||||
|
net::{TcpListener, TcpStream, UdpSocket},
|
||||||
|
sync::{Semaphore, watch},
|
||||||
|
task::{JoinHandle, JoinSet},
|
||||||
|
time::{Instant, sleep_until, timeout},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Options {
|
||||||
|
pub listen: SocketAddrV4,
|
||||||
|
pub max_sessions: usize,
|
||||||
|
pub handshake_timeout: Duration,
|
||||||
|
pub connect_timeout: Duration,
|
||||||
|
/// Grace period for the remaining direction after either side reaches EOF.
|
||||||
|
pub tcp_close_timeout: Duration,
|
||||||
|
pub udp_idle_timeout: Duration,
|
||||||
|
pub udp_io_timeout: Duration,
|
||||||
|
pub max_udp_targets: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Options {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
listen: SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0),
|
||||||
|
max_sessions: 128,
|
||||||
|
handshake_timeout: Duration::from_secs(5),
|
||||||
|
connect_timeout: Duration::from_secs(10),
|
||||||
|
tcp_close_timeout: Duration::from_secs(30),
|
||||||
|
udp_idle_timeout: Duration::from_secs(120),
|
||||||
|
udp_io_timeout: Duration::from_secs(5),
|
||||||
|
max_udp_targets: 64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Counters {
|
||||||
|
active: AtomicUsize,
|
||||||
|
accepted: AtomicU64,
|
||||||
|
completed: AtomicU64,
|
||||||
|
failed: AtomicU64,
|
||||||
|
refused_at_capacity: AtomicU64,
|
||||||
|
dropped_udp: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Snapshot {
|
||||||
|
pub running: bool,
|
||||||
|
pub active_sessions: usize,
|
||||||
|
pub accepted_sessions: u64,
|
||||||
|
pub completed_sessions: u64,
|
||||||
|
pub failed_sessions: u64,
|
||||||
|
pub refused_at_capacity: u64,
|
||||||
|
pub dropped_udp_packets: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Active(Arc<Counters>);
|
||||||
|
impl Drop for Active {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.active.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the listener and its sessions, but not the supplied network.
|
||||||
|
/// Stop the bridge before stopping the network. Dropping aborts all sessions;
|
||||||
|
/// `stop` additionally waits for resources to be released.
|
||||||
|
pub struct Bridge {
|
||||||
|
address: SocketAddrV4,
|
||||||
|
shutdown: watch::Sender<bool>,
|
||||||
|
task: Option<JoinHandle<std::io::Result<()>>>,
|
||||||
|
counters: Arc<Counters>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bridge {
|
||||||
|
pub async fn start(network: Arc<Network>, options: Options) -> Result<Self> {
|
||||||
|
ensure!(
|
||||||
|
options.listen.ip().is_loopback(),
|
||||||
|
"SOCKS bridge must bind IPv4 loopback"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
options.max_sessions > 0 && options.max_sessions <= 4096,
|
||||||
|
"invalid session limit"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
options.max_udp_targets > 0 && options.max_udp_targets <= 1024,
|
||||||
|
"invalid UDP target limit"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
!options.handshake_timeout.is_zero()
|
||||||
|
&& !options.connect_timeout.is_zero()
|
||||||
|
&& !options.tcp_close_timeout.is_zero()
|
||||||
|
&& !options.udp_idle_timeout.is_zero()
|
||||||
|
&& !options.udp_io_timeout.is_zero(),
|
||||||
|
"timeouts must be positive"
|
||||||
|
);
|
||||||
|
let status = network.snapshot().await;
|
||||||
|
ensure!(
|
||||||
|
status["application_only"] == true,
|
||||||
|
"SOCKS bridge requires application mode"
|
||||||
|
);
|
||||||
|
ensure!(status["state"] == "Running", "network is not running");
|
||||||
|
let listener = TcpListener::bind(options.listen).await?;
|
||||||
|
let SocketAddr::V4(address) = listener.local_addr()? else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let counters = Arc::new(Counters::default());
|
||||||
|
let (shutdown, rx) = watch::channel(false);
|
||||||
|
let task = tokio::spawn(serve(listener, network, options, rx, counters.clone()));
|
||||||
|
Ok(Self {
|
||||||
|
address,
|
||||||
|
shutdown,
|
||||||
|
task: Some(task),
|
||||||
|
counters,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn local_addr(&self) -> SocketAddrV4 {
|
||||||
|
self.address
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Snapshot {
|
||||||
|
let c = &self.counters;
|
||||||
|
Snapshot {
|
||||||
|
running: self.task.as_ref().is_some_and(|task| !task.is_finished()),
|
||||||
|
active_sessions: c.active.load(Ordering::Relaxed),
|
||||||
|
accepted_sessions: c.accepted.load(Ordering::Relaxed),
|
||||||
|
completed_sessions: c.completed.load(Ordering::Relaxed),
|
||||||
|
failed_sessions: c.failed.load(Ordering::Relaxed),
|
||||||
|
refused_at_capacity: c.refused_at_capacity.load(Ordering::Relaxed),
|
||||||
|
dropped_udp_packets: c.dropped_udp.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&mut self) -> Result<()> {
|
||||||
|
self.shutdown.send_replace(true);
|
||||||
|
if let Some(task) = self.task.take() {
|
||||||
|
task.await??;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Bridge {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown.send_replace(true);
|
||||||
|
if let Some(task) = &self.task {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve(
|
||||||
|
listener: TcpListener,
|
||||||
|
network: Arc<Network>,
|
||||||
|
options: Options,
|
||||||
|
mut shutdown: watch::Receiver<bool>,
|
||||||
|
counters: Arc<Counters>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let permits = Arc::new(Semaphore::new(options.max_sessions));
|
||||||
|
let mut sessions = JoinSet::new();
|
||||||
|
let result = loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = shutdown.changed() => break Ok(()),
|
||||||
|
_ = sessions.join_next(), if !sessions.is_empty() => {},
|
||||||
|
accepted = listener.accept() => {
|
||||||
|
let (stream, peer) = match accepted { Ok(v) => v, Err(e) => break Err(e) };
|
||||||
|
let Ok(permit) = permits.clone().try_acquire_owned() else {
|
||||||
|
counters.refused_at_capacity.fetch_add(1, Ordering::Relaxed);
|
||||||
|
drop(stream);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let SocketAddr::V4(peer) = peer else { continue; };
|
||||||
|
counters.active.fetch_add(1, Ordering::Relaxed);
|
||||||
|
counters.accepted.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let active = Active(counters.clone());
|
||||||
|
let c = counters.clone();
|
||||||
|
let network = network.clone();
|
||||||
|
let options = options.clone();
|
||||||
|
sessions.spawn(async move {
|
||||||
|
let _permit = permit;
|
||||||
|
let _active = active;
|
||||||
|
if session(stream, peer, network, options, &c).await.is_err() {
|
||||||
|
c.failed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
c.completed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drop(listener);
|
||||||
|
sessions.shutdown().await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_address() -> SocketAddrV4 {
|
||||||
|
SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn session(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
peer: SocketAddrV4,
|
||||||
|
network: Arc<Network>,
|
||||||
|
options: Options,
|
||||||
|
counters: &Counters,
|
||||||
|
) -> Result<()> {
|
||||||
|
let request = timeout(options.handshake_timeout, async {
|
||||||
|
ensure!(negotiate(&mut stream).await?, "no acceptable SOCKS method");
|
||||||
|
Ok::<_, anyhow::Error>(request(&mut stream).await?)
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
let request = match request {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(code) => {
|
||||||
|
reply(&mut stream, code, empty_address()).await?;
|
||||||
|
anyhow::bail!("unsupported SOCKS request");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if request.command == 1 {
|
||||||
|
if !valid_target(request.address) {
|
||||||
|
reply(&mut stream, DENIED, empty_address()).await?;
|
||||||
|
anyhow::bail!("invalid mesh destination");
|
||||||
|
}
|
||||||
|
let result = timeout(
|
||||||
|
options.connect_timeout,
|
||||||
|
network.connect_tcp(request.address.into(), options.connect_timeout),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut overlay = match result {
|
||||||
|
Ok(Ok(stream)) => stream,
|
||||||
|
error => {
|
||||||
|
let code = match &error {
|
||||||
|
Ok(Err(e))
|
||||||
|
if e.downcast_ref::<std::io::Error>()
|
||||||
|
.is_some_and(|e| e.kind() == std::io::ErrorKind::ConnectionRefused) =>
|
||||||
|
{
|
||||||
|
REFUSED
|
||||||
|
}
|
||||||
|
_ => HOST_UNREACHABLE,
|
||||||
|
};
|
||||||
|
reply(&mut stream, code, empty_address()).await?;
|
||||||
|
anyhow::bail!("mesh connection unavailable");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let SocketAddr::V4(bound) = overlay.local_addr() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
reply(&mut stream, SUCCESS, bound).await?;
|
||||||
|
relay_tcp(&mut stream, &mut overlay, options.tcp_close_timeout).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !request.address.ip().is_unspecified() && request.address.ip() != peer.ip() {
|
||||||
|
reply(&mut stream, DENIED, empty_address()).await?;
|
||||||
|
anyhow::bail!("UDP source differs from control connection");
|
||||||
|
}
|
||||||
|
let setup = timeout(options.connect_timeout, async {
|
||||||
|
let SocketAddr::V4(local) = stream.local_addr()? else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let relay = UdpSocket::bind(SocketAddrV4::new(*local.ip(), 0)).await?;
|
||||||
|
let overlay = network.bind_udp(0, options.connect_timeout).await?;
|
||||||
|
Ok::<_, anyhow::Error>((relay, overlay))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let (relay, overlay) = match setup {
|
||||||
|
Ok(Ok(v)) => v,
|
||||||
|
_ => {
|
||||||
|
reply(&mut stream, FAILURE, empty_address()).await?;
|
||||||
|
anyhow::bail!("UDP setup failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let SocketAddr::V4(bound) = relay.local_addr()? else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
reply(&mut stream, SUCCESS, bound).await?;
|
||||||
|
udp_session(
|
||||||
|
stream,
|
||||||
|
peer,
|
||||||
|
request.address.port(),
|
||||||
|
relay,
|
||||||
|
overlay,
|
||||||
|
options,
|
||||||
|
counters,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn relay_tcp(
|
||||||
|
local: &mut TcpStream,
|
||||||
|
remote: &mut meshport::TcpStream,
|
||||||
|
close_timeout: Duration,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (mut local_read, mut local_write) = split(local);
|
||||||
|
let (mut remote_read, mut remote_write) = split(remote);
|
||||||
|
let (closed, mut closure) = watch::channel(false);
|
||||||
|
let upload = async {
|
||||||
|
copy(&mut local_read, &mut remote_write).await?;
|
||||||
|
// Notify before native shutdown: a FIN ACK may never arrive after
|
||||||
|
// a route disappears. Permit pending response data a bounded grace.
|
||||||
|
closed.send_replace(true);
|
||||||
|
remote_write.shutdown().await
|
||||||
|
};
|
||||||
|
let download = async {
|
||||||
|
copy(&mut remote_read, &mut local_write).await?;
|
||||||
|
closed.send_replace(true);
|
||||||
|
local_write.shutdown().await
|
||||||
|
};
|
||||||
|
let forwarding = async { tokio::try_join!(upload, download) };
|
||||||
|
tokio::pin!(forwarding);
|
||||||
|
tokio::select! {
|
||||||
|
result = &mut forwarding => { result?; }
|
||||||
|
_ = closure.changed() => { timeout(close_timeout, forwarding).await??; }
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn udp_session(
|
||||||
|
mut control: TcpStream,
|
||||||
|
peer: SocketAddrV4,
|
||||||
|
source_port: u16,
|
||||||
|
relay: UdpSocket,
|
||||||
|
overlay: meshport::UdpSocket,
|
||||||
|
options: Options,
|
||||||
|
counters: &Counters,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut endpoint = (source_port != 0).then_some(SocketAddrV4::new(*peer.ip(), source_port));
|
||||||
|
let mut targets = HashSet::new();
|
||||||
|
let mut local_buffer = vec![0; 65535];
|
||||||
|
let mut remote_buffer = vec![0; 65535];
|
||||||
|
let mut control_buffer = [0; 1];
|
||||||
|
let mut idle = Instant::now() + options.udp_idle_timeout;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Association lifetime is tied to its TCP control channel.
|
||||||
|
result = control.read(&mut control_buffer) => {
|
||||||
|
result?;
|
||||||
|
return Ok(()); // EOF or unexpected control payload closes it.
|
||||||
|
}
|
||||||
|
_ = sleep_until(idle) => return Ok(()),
|
||||||
|
result = relay.recv_from(&mut local_buffer) => {
|
||||||
|
let (len, source) = result?;
|
||||||
|
let SocketAddr::V4(source) = source else { continue; };
|
||||||
|
let decoded = udp_request(&local_buffer[..len]);
|
||||||
|
if source.ip() != peer.ip() || endpoint.is_some_and(|e| e != source) || decoded.is_none() {
|
||||||
|
counters.dropped_udp.fetch_add(1, Ordering::Relaxed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (target, payload) = decoded.unwrap();
|
||||||
|
if !targets.contains(&target) && targets.len() >= options.max_udp_targets {
|
||||||
|
counters.dropped_udp.fetch_add(1, Ordering::Relaxed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
endpoint = Some(source);
|
||||||
|
// Count attempted destinations too: the native socket can register
|
||||||
|
// a flow before a send fails. This bounds its resource growth.
|
||||||
|
targets.insert(target);
|
||||||
|
match timeout(options.udp_io_timeout, overlay.send_to(payload, target.into())).await {
|
||||||
|
Ok(Ok(n)) if n == payload.len() => idle = Instant::now() + options.udp_idle_timeout,
|
||||||
|
_ => { counters.dropped_udp.fetch_add(1, Ordering::Relaxed); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = overlay.recv_from(&mut remote_buffer) => {
|
||||||
|
let (len, source) = result?;
|
||||||
|
let SocketAddr::V4(source) = source else { continue; };
|
||||||
|
let Some(endpoint) = endpoint else { continue; };
|
||||||
|
if !targets.contains(&source) { continue; }
|
||||||
|
let Some(frame) = udp_reply(source, &remote_buffer[..len]) else {
|
||||||
|
counters.dropped_udp.fetch_add(1, Ordering::Relaxed);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
timeout(options.udp_io_timeout, relay.send_to(&frame, endpoint)).await??;
|
||||||
|
idle = Instant::now() + options.udp_idle_timeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user