Author SHA1 Message Date
shenleiandClaude Opus 5.5 e70a6abf7a Merge main into feature/ava2-unlock
Conflicts resolved by keeping both sides: AVA2 attribute lock materials
(50348/50349) and battle pass tickets from main alongside the unlock
branch's costume enchants, talismans and new rings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:14:39 +09:00
shenandshen 507e687867 feat: add MeshPort overlay networking, item actions window, and AVA2 client enhancements
- add MeshPort peer-to-peer overlay networking SDK, C++ integration, and Python bindings
- add item actions quick use/split/delete/lock overlay window (mt_item_actions)
- add shop name range and NPC name visibility settings to system display options
- support battlepass ticket activation and month validation in mt_battlepass
- support boss tracking teleport destination button in mt_bosses
- support asynchronous account registration cancellation and pending login cancellation
- update AVA2 item conversion for attr lock scrolls and battlepass tickets
- update .gitignore for local build outputs and caches
2026-10-16 01:29:57 -07:00
shenleiandClaude Opus 5.5 8d01a7a754 AVA2 unlock P8: new rings client (mt_rings, ring slots, item import)
ITEM_TYPE_RINGS / c_Equipment_Rings1/2, mt_rings adds the two slots beside
the shoes, the accessory tooltip and the drill / jewel / switcher / adder drop
targets; make_items imports the four +0..+9 ring chains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:40:40 +09:00
shenleiandClaude Opus 5.5 b9af78ef64 AVA2 unlock P7: talismans (护符) and their attribute items
- ARMOR_PENDANT / APPLY_ENCHANT_* 123-128 / c_Equipment_Pendant (server WEAR_PENDANT 27) constants
- mt_pendant: equipment slot under the earring, drop targets for 71302/53996 (reroll) and 71301/53997
  (add, max 5), MTPENDANT messages; enchant apply texts in mt_applies
- switchbot ignores talismans and talisman rerolls
- make_items keeps the six +0..+200 chains (1206 items) and the four attribute items
- tools/server/pendant: refine sets 440-499 (AVA2 rows) + install.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:26:12 +09:00
shenleiandClaude Opus 5.5 a1b63c0afb AVA2 unlock P6: costume enchants (client)
Keep 54001-54078 (body/hair) and 54124-54174 (mount skin) enchants as
USE_CHANGE_ATTRIBUTE; mt_costume_attr marks matching costumes as drop
targets, shows the attribute in the tooltip and renders MTCATTR; the
switchbot no longer lists enchants as scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:07:07 +09:00
shenleiandClaude Opus 5.5 1412806ee9 AVA2 unlock P5: mount costume / skin (COSTUME_MOUNT 2, COSTUME_MOUNT_SKIN 4, slots 25/26, mt_mount, npc_mount races)
Keeps 71502/71505/71508/71511 and skins 71234/71254 as costumes, stages the
npc_mount models of races 32012/32028, writes mobs-server.tsv and lets
install_items.py install mob rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:58:16 +09:00
shenleiandClaude Opus 5.5 4d7306792e AVA2 unlock P4: sash (mt_sash windows/slot/tooltips, sash module, CHR_EQUIPPART_SASH, item_scale)
Client side of metin2-server FEATURE-AVA2-UNLOCK P4: HEADER_CG_SASH 211 /
HEADER_GC_SASH 215, a fifth awPart WORD in the character packets (old
servers break), the sash part attached by scale per job/sex, the combine
and absorb windows in script mt_sash, and quest tools/server/sash on 20016.
The ava2items pack keeps the four grade-4 sashes plus their effects,
acce window images and item_scale.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:45:02 +09:00
shenleiandClaude Opus 5.5 0d2a9ebe36 AVA2 unlock P3: dragon armor 21210-21279 (+0..+9) with armor shape 32
Keep ARMOR_BODY rows, import the whole refine chain, ship AVA2's <race>_7_1
models and register shape 32 after each race msm load (mt_shapes): the race
msm are read by bare name from the root pack and cannot be overridden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:13:41 +09:00
shenleiandClaude Opus 5.5 178fcf48fe AVA2 unlock P1/P2: treasure boxes/keys and stone/Boss stones usable
Keep ITEM_TREASURE_BOX/KEY and METIN_NORMAL rows; APPLY_ATTBONUS_STONE 139 /
APPLY_ATTBONUS_BOSS 140 in ItemData.h, item module and tooltip (mt_applies).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:05:20 +09:00
shenlei f266072f67 Merge feature/ava2-items 2026-10-16 10:10:50 +09:00
shenleiandClaude Opus 5.5 90f213c4cc Import the AVA2 drop items the server lacks as ordinary 40250 items
tools/server/ava2-items stages the 478 items referenced by the AVA2 drop
tables that 203 does not have: server item_proto/item_names rows, a client
item_proto/item_list/itemdesc patch over the biolog tables, and the AVA2
icons, ground models (BitKnit gr2 via sdk/gr2) and their textures, packed
as ava2items ahead of biolog. Materials, quest items, chests, the guardian
weapons and shoes keep their meaning; items of AVA2-only systems (pets,
pendants, new rings, mounts, sashes, costume enchants, talisman items, new
skill books) become plain ITEM_NONE items until those systems are ported.
Names are translated in names_zh.tsv.

item_proto_patch is the full-record successor of biolog_proto_patch, with
--dump for checking conversions against the shipped table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:10:47 +09:00
shenleiandClaude Opus 5.5 d11d46c469 Fix the zh locale build after the item shapes overlay
A stray comma turned the proto patch argument list into a tuple, so the
build failed with "bad operand type for unary +" since 5dcac9cb. The AVA2
biology step also accepts the reference unpacked under AVA2/TEST instead of
only the FILES+SRC.tgz archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:31:52 +09:00
602 changed files with 215952 additions and 60 deletions
+14
View File
@@ -44,3 +44,17 @@ __pycache__/
/android/app/build/
/android/build/
/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/
+1
View File
@@ -30,6 +30,7 @@ endif()
add_subdirectory(third_party)
add_subdirectory(src/codepage)
if(MT_BUILD_PORT OR MT_BUILD_NATIVE_RENDER)
add_subdirectory(src/meshport)
add_subdirectory(src/port)
endif()
+4 -1
View File
@@ -4,9 +4,10 @@
## macOS
需要 CMake、SDL3、Vulkan loader、MoltenVK、glslc(第三方库均已随仓库提供源码,无需子模块):
需要 Rust 1.95+、protoc、CMake、SDL3、Vulkan loader、MoltenVK、glslc(第三方库均已随仓库提供源码,无需子模块):
```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
@@ -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
```
MeshPort SDK 源码在 `third_party/meshport`,无需单独检出 MeshPort 项目。频道查询、登录、注册、地图/公会连接及 UDP 都在公共网络层判断:同网段直连,跨网段组网,使用 DHCP 动态 IP;远端连接验证成功后提示“组网成功”。私有配置与构建说明见 [docs/MESHPORT.md](docs/MESHPORT.md)。
详见 [src/host/README.md](src/host/README.md)。
## Android
+4
View File
@@ -74,11 +74,15 @@ if [ ! -f "$OUT/sdl3/lib/libSDL3.so" ] || [ ! -f "$OUT/sdl3/.page16k" ]; then
touch "$OUT/sdl3/.page16k"
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 ---
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_BUILD_TYPE="$CONFIG" -DMT_BUILD_TOOLS=OFF -DBUILD_TESTING=OFF \
-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
cmake --build "$OUT/app-build" --target main mt_native_android_assets -j "$JOBS"
+89
View File
@@ -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`。
+24
View File
@@ -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
+46 -7
View File
@@ -356,7 +356,7 @@ def build(en: Path, zh: Path, output: Path, client_base: Path,
[str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"),
str(item_names), str(mob_names), str(locale_dir / "item_proto"),
str(locale_dir / "mob_proto")] + ([str(limits)] if limits.is_file() else [])
+ ([str(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,
@@ -397,7 +397,10 @@ def build_biolog(output: Path, pack_tool: Path):
root = REPO / "tools/server/biolog"
ava = REPO.parent / "AVA2"
archive = ava / "2. Backup SF+SRC and SQL/FILES+SRC.tgz"
if not archive.is_file():
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")
proto_tool = tool("biolog_proto_patch")
if not proto_tool.is_file():
@@ -405,11 +408,14 @@ def build_biolog(output: Path, pack_tool: Path):
stage = output.parent / "biolog"
stage.mkdir(parents=True, exist_ok=True)
proto = stage / "ava2-item-proto.txt"
with tarfile.open(archive, "r:gz") as source:
member = source.extractfile("home/srv1/db/item_proto.txt")
if member is None:
raise ValueError("AVA2 item table missing")
proto.write_bytes(member.read())
if not archive.is_file():
shutil.copyfile(extracted, proto)
else:
with tarfile.open(archive, "r:gz") as source:
member = source.extractfile("home/srv1/db/item_proto.txt")
if member is None:
raise ValueError("AVA2 item table missing")
proto.write_bytes(member.read())
spec = importlib.util.spec_from_file_location("biolog_assets", root / "make_assets.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
@@ -425,6 +431,38 @@ def build_biolog(output: Path, pack_tool: Path):
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):
"""A versioned public server catalogue; original script and proto packs stay intact."""
@@ -466,6 +504,7 @@ def main():
args.proto_tool, args.name_tables, args.pack_tool)
report = json.loads(report_path.read_text())
report["biolog"] = build_biolog(args.output, args.pack_tool)
report["ava2_items"] = build_ava2_items(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")
print(f"Built {args.output / 'pack/locale_zh.eix'} (.epk), registered in pack/Index")
+65
View File
@@ -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()
+3
View File
@@ -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";
+4911
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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;}
}
}
+158
View File
@@ -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()));
}
+359
View File
@@ -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;
}
}
+26
View File
@@ -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();
}
+4
View File
@@ -21,3 +21,7 @@ if(TARGET mtpython)
set_property(SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/ScriptLib/PythonBoot.cpp
PROPERTY COMPILE_DEFINITIONS ${MT_PORT_DEFINES_UserInterface})
endif()
if(TARGET mt_meshport)
target_include_directories(port_platform PRIVATE "${PROJECT_BINARY_DIR}/src/meshport")
endif()
+28
View File
@@ -0,0 +1,28 @@
// PORT: item bonuses added after 40250 (AVA2 numbering, server common/length.h). The packed uitooltip.py
// only knows 40250's applies; add the new ones to its AFFECT_DICT so item and socket tooltips show them.
static const char c_szAppliesScript[] = R"PY(# -*- coding: utf-8 -*-
import item
import player
import uiToolTip
L = player.AutoHuntLocalize
TEXTS = (
("APPLY_ATTBONUS_STONE", "对陨石伤害 +%d%%"),
("APPLY_ATTBONUS_BOSS", "对 Boss 伤害 +%d%%"),
("APPLY_ENCHANT_ELEC", "对雷属性怪物伤害 +%d%%"),
("APPLY_ENCHANT_FIRE", "对火属性怪物伤害 +%d%%"),
("APPLY_ENCHANT_ICE", "对冰属性怪物伤害 +%d%%"),
("APPLY_ENCHANT_WIND", "对风属性怪物伤害 +%d%%"),
("APPLY_ENCHANT_EARTH", "对土属性怪物伤害 +%d%%"),
("APPLY_ENCHANT_DARK", "对暗属性怪物伤害 +%d%%"),
)
def Text(text):
return lambda value: L(text % value)
for name, text in TEXTS:
if hasattr(item, name):
uiToolTip.ItemToolTip.AFFECT_DICT[getattr(item, name)] = Text(text)
)PY";
@@ -0,0 +1,34 @@
// PORT: armor shapes added after 40250 (AVA2's dragon armor 21210+, item value3 = 32). The race .msm files are
// read by bare name, which CEterPackManager always serves from the root pack, so a patch pack cannot replace
// them; register the extra shapes after each LoadLocalRaceData instead, as the AVA2 msm groups would. Models
// ship in the ava2items pack (metin2-client tools/server/ava2-items, ARMOR_SHAPES / RACE_PATHS).
static const char c_szArmorShapesScript[] = R"PY(# -*- coding: utf-8 -*-
import chrmgr
SHAPES = {32: "%s_7_1"}
# ShapeData PathName of each race msm (male, female).
PATHS = {"warrior": ("pc", "pc2"), "assassin": ("pc2", "pc"), "sura": ("pc", "pc2"), "shaman": ("pc2", "pc")}
_load = chrmgr.LoadLocalRaceData
def Register(name):
stem = name.lower().split(".")[0]
race, _, sex = stem.partition("_")
if race not in PATHS or sex not in ("m", "w"):
return
path = "d:/ymir work/%s/%s/" % (PATHS[race][sex == "w"], race)
for shape, model in SHAPES.items():
model = path + model % race
chrmgr.SetShapeModel(shape, model + ".gr2")
chrmgr.AppendShapeSkin(shape, 0, model + ".dds", model + ".dds")
def LoadLocalRaceData(name):
result = _load(name)
Register(name)
return result
chrmgr.LoadLocalRaceData = LoadLocalRaceData
)PY";
+65 -9
View File
@@ -11,6 +11,7 @@ import grp
import item
import net
import nonplayer
import player
import ui
import weakref
from mt_biolog import Action, Text, L
@@ -29,7 +30,8 @@ TYPES={1:('击杀怪物','击杀 ','mob'),2:('击杀 Boss','击杀 Boss ','mob')
9:(None,'向 NPC 出售 ','item')}
ERRORS={'loading':'数据加载中,请稍后再试','slot':'没有这个任务','claimed':'奖励已经领取过了','incomplete':'任务尚未完成',
'space':'背包空间不足,请整理后再领取','config':'奖励配置有误,请联系管理员','db':'服务器繁忙,请稍后再试',
'rate':'操作过快,请稍后再试','invalid':'参数无效'}
'rate':'操作过快,请稍后再试','invalid':'参数无效','ticket':'请使用当月战令票激活','month':'这张战令票不属于当前月份',
'activated':'本月战令已经激活过了','busy':'请先结束交易、商店、仓库或传送'}
def Number(text,low=0,high=10**12):
@@ -86,7 +88,7 @@ class Pass(object):
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.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
@@ -104,10 +106,15 @@ class Pass(object):
return self.Post('/mt_bp 1 info')
def Claim(self,slot):
if self.claiming is not None or not self.season:return False
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')
@@ -135,7 +142,10 @@ class Pass(object):
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)
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),
@@ -145,7 +155,7 @@ class Pass(object):
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.none=False
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')
@@ -214,6 +224,8 @@ class BattlePassWindow(ui.Window):
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()
@@ -232,8 +244,43 @@ class BattlePassWindow(ui.Window):
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)
@@ -264,7 +311,7 @@ class BattlePassWindow(ui.Window):
if m['claimed']:row['button'].SetText('已领取')
elif s.claiming==m['slot']:row['button'].SetText('领取中')
else:row['button'].SetText('领取' if done else '未完成')
row['button'].SetEnabled(done and not m['claimed'] and bool(m['rewards']) and s.claiming is None)
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()
@@ -274,7 +321,11 @@ class BattlePassWindow(ui.Window):
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(ready and not f['claimed'] and s.claiming is None)
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()
@@ -288,6 +339,8 @@ class BattlePassWindow(ui.Window):
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))
@@ -305,7 +358,7 @@ class BattlePassWindow(ui.Window):
def OnPressEscapeKey(self):self.Hide();return True
def Destroy(self):
self.Hide();self.closeButton.event=self.finalButton.event=self.prev.event=self.next.event=None
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
@@ -327,7 +380,10 @@ def OnPass(kind,value):
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():g_window.Refresh()
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)
+27 -1
View File
@@ -24,6 +24,20 @@ g_tick=None
PAGE=8
REFRESH=30.
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.
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',
@@ -117,10 +131,11 @@ class BossWindow(ui.Window):
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.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.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):
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
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):
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 '未同步,请稍后刷新'))
@@ -156,6 +177,7 @@ class BossWindow(ui.Window):
self.prevButton.SetEnabled(self.page>0);self.nextButton.SetEnabled(self.page+1<self.Pages())
for i in range(PAGE):
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
r=rows[index];left=t.Left(r)
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.SetEnabled(bool(r['alive']==0 and r['next']) or Key(r) in t.reminders)
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):
dims=(wndMgr.GetScreenWidth(),wndMgr.GetScreenHeight())
@@ -179,6 +204,7 @@ class BossWindow(ui.Window):
for i in range(PAGE):
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.travelButtons[i].SetPosition(width-190,y)
self.note.SetPosition(18,height-22);self.SetCenterPosition()
def OnUpdate(self):
@@ -0,0 +1,83 @@
// PORT: AVA2 costume enchants (ENABLE_SPECIAL_COSTUME_ATTR), as module mt_costume_attr. Items 54001-54267 are
// USE_CHANGE_ATTRIBUTE rows with value0 = apply, value1 = value, value2 = target mask (1 << bonus flag); the server
// (metin2-server port_costume_attr.cpp) adds that attribute to a body / hair / mount-skin costume. The 40250
// inventory already sends every USE_CHANGE_ATTRIBUTE drop to the server; this script only marks the matching
// costumes as usable targets, shows the attribute in the enchant's tooltip and renders the "MTCATTR <code>" server
// messages that replace AVA2's locale strings 1056 / 1057 / 1061 / 1062 / 1063.
static const char c_szCostumeAttrScript[] = R"PY(# -*- coding: utf-8 -*-
import chat
import game
import item
import player
import uiInventory
import uiToolTip
L=player.AutoHuntLocalize
VNUMS=(54001,54267)
MAX_ATTR=3
# AVA2 CItem::GetBonusFlag of the costume kinds the server maps -> (mask bit, target name).
TARGETS={item.COSTUME_TYPE_BODY:(1,'时装'),item.COSTUME_TYPE_HAIR:(2,'发型'),item.COSTUME_TYPE_MOUNT_SKIN:(4,'坐骑外观')}
MESSAGES={'target':'该强化石不能用于这件物品。','none':'该强化石没有可附加的属性。','equipped':'请先卸下时装再附加属性。',
'full':'这件时装的附加属性已满(最多 3 条)。','has':'这件时装已有该属性。','done':'附加属性成功。'}
def Enchant(vnum):
"""(apply, value, mask) of a costume enchant, else None (selects vnum)."""
if not VNUMS[0]<=vnum<=VNUMS[1]:return None
item.SelectItem(vnum)
if item.GetItemType()!=item.ITEM_TYPE_USE or item.GetUseType(vnum)!="USE_CHANGE_ATTRIBUTE" or not item.GetValue(2):return None
return (item.GetValue(0),item.GetValue(1),item.GetValue(2))
def CanEnchant(enchant,dstSlot):
dstVnum=player.GetItemIndex(dstSlot)
if not dstVnum or player.IsEquipmentSlot(dstSlot):return False
item.SelectItem(dstVnum)
if item.GetItemType()!=item.ITEM_TYPE_COSTUME:return False
target=TARGETS.get(item.GetItemSubType())
if not target or not enchant[2]&(1<<target[0]):return False
attrs=[player.GetItemAttribute(dstSlot,i) for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM)]
used=[a for a in attrs if a[0]]
return len(used)<MAX_ATTR and enchant[0] not in [a[0] for a in used]
_canUse=uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem
def CanUseSrcItemToDstItem(self,srcItemVNum,srcSlotPos,dstSlotPos):
enchant=Enchant(srcItemVNum)
if enchant is None:return _canUse(self,srcItemVNum,srcSlotPos,dstSlotPos)
return srcSlotPos!=dstSlotPos and CanEnchant(enchant,dstSlotPos)
uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem=CanUseSrcItemToDstItem
Tip=uiToolTip.ItemToolTip
_addItemData=Tip.AddItemData
def AddItemData(self,itemVnum,metinSlot,attrSlot=0,flags=0,unbindTime=0):
result=_addItemData(self,itemVnum,metinSlot,attrSlot,flags,unbindTime)
enchant=Enchant(itemVnum)
if enchant:
self.AppendSpace(5)
affect=self._ItemToolTip__GetAffectString(enchant[0],enchant[1])
if affect:self.AppendTextLine(affect,self.POSITIVE_COLOR)
names=[name for bit,name in sorted(TARGETS.values()) if enchant[2]&(1<<bit)]
if names:self.AppendTextLine(L('适用:'+'、'.join(names)),self.CONDITION_COLOR)
self.ResizeToolTip()
return result
Tip.AddItemData=AddItemData
_command=game.GameWindow.BINARY_ServerCommand_Run
def Command(self,line):
if line.startswith('MTCATTR '):
words=line.split()
text=MESSAGES.get(words[1] if len(words)>1 else '')
if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text))
return 1
return _command(self,line)
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";
+20 -8
View File
@@ -21,18 +21,22 @@ LEVEL_HINTS=('显示所有角色的特效','隐藏其他玩家和NPC的特效,
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):
words=text.split()
if len(words)!=3 or not all(w.isdigit() and len(w)==1 for w in words):return
effect,tree,cloud=[int(w) for w in words]
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
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[:3]]
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):
systemSetting.SetEffectLevel(self.effect)
systemSetting.SetShopNamesRange(self.shopRange)
systemSetting.SetShopNameStatus(self.npcNames)
background.SetVisiblePart(background.PART_TREE,int(self.tree))
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):
def __init__(self,options):
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.closeButton=Action(self,256,6,60,'关闭',self.Hide)
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.nameButton=Action(self,18,312,144,'',self.ToggleName)
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()
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()
@@ -80,6 +86,10 @@ class DisplayWindow(ui.Window):
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).
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.nameButton.SetText(Mark(u'总是显示名字',systemSetting.IsAlwaysShowName()))
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]))
def OnRender(self):
+12 -1
View File
@@ -33,6 +33,7 @@ class DungeonWindow(ui.Window):
self.rows=[Text(self,24,142+32*i,'',0xffefdfc3) for i in range(6)]
self.remindButton=Action(self,18,0,146,'到期提醒:关',self.Remind)
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.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.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))
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):
if self.values is None or self.stale:return
@@ -124,6 +134,7 @@ class DungeonWindow(ui.Window):
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.closeButton.SetPosition(width-76,6)
self.travelButton.SetPosition(342,height-74)
spacing=min(32,max(16,(height-250)//5))
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)
@@ -148,7 +159,7 @@ class DungeonWindow(ui.Window):
def Destroy(self):
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):
@@ -6,7 +6,7 @@ import net
import ui
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
g_service=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";
+30
View File
@@ -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
+52
View File
@@ -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()
if _last[3]:
self.ShowToolTip(_last[2])
import sys
actions = sys.modules.get('mt_item_actions')
if actions:actions.ShowInventory(self, _last[2])
return
snapshot = _Snapshot(self, slot) if dragging and not mouse.isAttached() else None
result = _OriginalSelect(self, slot)
+79
View File
@@ -0,0 +1,79 @@
// PORT: AVA2 mount costume + mount skin (ENABLE_MOUNT_COSTUME_SYSTEM / ENABLE_MOUNT_SKIN), as module mt_mount.
// The server swaps the horse race for the worn costume's value0 (metin2-server port_mount.cpp); this script
// registers the AVA2 npclist rows of the skin races (npclist.txt is read from the root pack only, so a patch
// pack cannot add them; RaceManager's __IsNewMountRace adds npc_mount to their search path), adds the mount
// and skin slots to the costume window under the sash slot, and renders the "MTMOUNT <code>" server
// messages that replace AVA2's locale strings 334 / 1097 / 1098. Models ship in the ava2items pack
// (tools/server/ava2-items, MOUNT_MOBS).
static const char c_szMountScript[] = R"PY(# -*- coding: utf-8 -*-
import chat
import chrmgr
import game
import item
import player
import ui
import uiInventory
L=player.AutoHuntLocalize
# AVA2 root/npclist.txt rows of the races MOUNT_MOBS ships.
RACES=((32012,"christmas_2017_sleddog_02"),(32028,"christmas_2020_alpaca"))
SRC_NAMES=(("christmas_2017_sleddog_02","christmas_2017_sleddog"),)
MESSAGES={'riding':'骑乘中无法更换坐骑时装。','need_mount':'需要先装备坐骑时装。','has_skin':'请先卸下坐骑外观。',
'need_horse':'需要先拥有马匹(马匹等级 1 以上)才能召唤坐骑。'}
# Costume window: left column under the sash slot (CostumeSlot coordinates; body is at 61,45).
SLOTS=((item.COSTUME_SLOT_MOUNT,(13,45)),(item.COSTUME_SLOT_MOUNT_SKIN,(13,82)))
for race,name in RACES:chrmgr.RegisterRaceName(race,name)
for name,src in SRC_NAMES:chrmgr.RegisterRaceSrcName(name,src)
def FindChild(node,name):
if node.get("name")==name:return node
for child in node.get("children",()):
found=FindChild(child,name)
if found:return found
return None
def PatchCostumeWindow(window):
base=FindChild(window,"Costume_Base");slot=FindChild(window,"CostumeSlot")
if not base or not slot:return
frames=[];slots=[]
for index,(x,y) in SLOTS:
frames.append({"name":"MountSlotBase%d"%index,"type":"image","x":slot["x"]+x,"y":slot["y"]+y,"image":"d:/ymir work/ui/public/Slot_Base.sub"})
slots.append({"index":index,"x":x,"y":y,"width":32,"height":32})
base["children"]=tuple(frames)+tuple(base.get("children",()))
slot["slot"]=tuple(slot["slot"])+tuple(slots)
_execfile=ui.execfile
def ExecFile(fileName,dict):
_execfile(fileName,dict)
name=fileName.replace("\\","/").split("/")[-1].lower()
if name=="costumewindow.py" and "window" in dict:PatchCostumeWindow(dict["window"])
ui.execfile=ExecFile
_refreshCostume=uiInventory.CostumeWindow.RefreshCostumeSlot
def RefreshCostumeSlot(self):
_refreshCostume(self)
for index,_ in SLOTS:self.wndEquip.SetItemSlot(index,player.GetItemIndex(index),0)
self.wndEquip.RefreshSlot()
uiInventory.CostumeWindow.RefreshCostumeSlot=RefreshCostumeSlot
_command=game.GameWindow.BINARY_ServerCommand_Run
def Command(self,line):
if line.startswith('MTMOUNT '):
words=line.split()
text=MESSAGES.get(words[1] if len(words)>1 else '')
if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text))
return 1
return _command(self,line)
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";
+100
View File
@@ -0,0 +1,100 @@
// PORT: AVA2 talisman (ITEM_TALISMAN_EQUIPMENT + ELEMENT_NEW_BONUSES), as module mt_pendant. Talismans are
// ITEM_ARMOR / ARMOR_PENDANT worn in c_Equipment_Pendant (server WEAR_PENDANT, metin2-server port_pendant.cpp).
// This script adds that slot to the inventory's equipment panel (the free spot under the earring on
// equipment_base), marks unequipped talismans as drop targets of the talisman attribute items (71302 / 53996
// reroll, 71301 / 53997 add up to 5) and nothing else, and renders the "MTPENDANT <code>" server messages that
// replace AVA2's locale strings 1056 / 1057 / 1067.
static const char c_szPendantScript[] = R"PY(# -*- coding: utf-8 -*-
import chat
import game
import item
import player
import ui
import uiInventory
L=player.AutoHuntLocalize
CHANGE=(71302,53996)
ADD=(71301,53997)
MAX_ATTR=5
MESSAGES={'target':'该道具只能用于护符。','equipped':'请先卸下护符再使用。','empty':'这件护符没有可重洗的属性。',
'changed':'护符属性已重新随机。','full':'这件护符的属性已满(最多 5 条)。','added':'护符追加属性成功。'}
# EquipmentSlot coordinates of the slot (the earring is at 114,84).
SLOT=(item.EQUIPMENT_PENDANT,(114,118))
def FindChild(node,name):
if node.get("name")==name:return node
for child in node.get("children",()):
found=FindChild(child,name)
if found:return found
return None
def PatchInventoryWindow(window):
base=FindChild(window,"Equipment_Base");slot=FindChild(window,"EquipmentSlot")
if not base or not slot:return
index,(x,y)=SLOT
frame={"name":"PendantSlotBase","type":"image","x":slot["x"]+x,"y":slot["y"]+y,"image":"d:/ymir work/ui/public/Slot_Base.sub"}
base["children"]=(frame,)+tuple(base.get("children",()))
slot["slot"]=tuple(slot["slot"])+({"index":index,"x":x,"y":y,"width":32,"height":32},)
_execfile=ui.execfile
def ExecFile(fileName,dict):
_execfile(fileName,dict)
name=fileName.replace("\\","/").split("/")[-1].lower()
if name=="inventorywindow.py" and "window" in dict:PatchInventoryWindow(dict["window"])
ui.execfile=ExecFile
_refreshEquip=uiInventory.InventoryWindow.RefreshEquipSlotWindow
def RefreshEquipSlotWindow(self):
_refreshEquip(self)
index=SLOT[0]
self.wndEquip.SetItemSlot(index,player.GetItemIndex(index),0)
self.wndEquip.RefreshSlot()
uiInventory.InventoryWindow.RefreshEquipSlotWindow=RefreshEquipSlotWindow
def IsTalisman(vnum):
if not vnum:return False
item.SelectItem(vnum)
return item.GetItemType()==item.ITEM_TYPE_ARMOR and item.GetItemSubType()==item.ARMOR_PENDANT
def AttrCount(slot):
return len([1 for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM) if player.GetItemAttribute(slot,i)[0]])
_canUse=uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem
def CanUseSrcItemToDstItem(self,srcItemVNum,srcSlotPos,dstSlotPos):
scroll=srcItemVNum in CHANGE or srcItemVNum in ADD
talisman=IsTalisman(player.GetItemIndex(dstSlotPos))
if not scroll:
# the server refuses every other attribute / socket item on a talisman
if talisman and item.GetUseType(srcItemVNum) in self.USE_TYPE_TUPLE:return False
return _canUse(self,srcItemVNum,srcSlotPos,dstSlotPos)
if srcSlotPos==dstSlotPos or not talisman or player.IsEquipmentSlot(dstSlotPos):return False
count=AttrCount(dstSlotPos)
return count>0 if srcItemVNum in CHANGE else count<MAX_ATTR
uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem=CanUseSrcItemToDstItem
_command=game.GameWindow.BINARY_ServerCommand_Run
def Command(self,line):
if line.startswith('MTPENDANT '):
words=line.split()
text=MESSAGES.get(words[1] if len(words)>1 else '')
if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text))
return 1
return _command(self,line)
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";
+39
View File
@@ -37,6 +37,7 @@
#include "UserInterface/PythonQuest.h"
#include "UserInterface/PythonMessenger.h"
#include "UserInterface/PythonSafeBox.h"
#include "UserInterface/PythonSash.h"
#include "UserInterface/PythonGuild.h"
#include "UserInterface/MarkManager.h"
#include "UserInterface/GuildMarkDownloader.h"
@@ -47,6 +48,9 @@
#include "UserInterface/CRenderTargetManager.h"
#include "EterBase/Timer.h"
#include "PythonBoot.h"
#ifdef MT_HAS_MESHPORT
#include "transport.h"
#endif
#include "../UserInterface/UserInterface.h"
#include "PythonHost.h"
#include "../EterLib/UIRenderCommands.h"
@@ -153,6 +157,7 @@ struct GameSingletons
CPythonQuest pyQuest;
CPythonMessenger pyManager;
CPythonSafeBox pySafeBox;
CPythonSash pySash; // PORT: AVA2 sash
CPythonGuild pyGuild;
CGuildMarkManager kGuildMarkManager;
CGuildMarkDownloader kGuildMarkDownloader;
@@ -250,6 +255,10 @@ void* fiber_main(void*)
}
void init_2v0_gameplay_stubs();
void StopAccountRegistration(); // PORT: drain registration before networking teardown.
#ifdef MT_HAS_MESHPORT
void initMeshPort();
#endif
namespace PythonBoot
{
@@ -899,6 +908,9 @@ bool run_main_script_body(const char* lpCmdLine, std::string* error)
initChat();
initTextTail();
initnet();
#ifdef MT_HAS_MESHPORT
initMeshPort();
#endif
initMiniMap();
initEvent();
initeffect();
@@ -911,6 +923,7 @@ bool run_main_script_body(const char* lpCmdLine, std::string* error)
initBackground();
initMessenger();
initsafebox();
initSash();
initguild();
initServerStateChecker();
initRenderTarget(); // PORT: AVA2 model preview
@@ -1011,6 +1024,17 @@ namespace PythonBoot
#include "VipScript.inc"
#include "ModelPreviewScript.inc"
#include "BattlePassScript.inc"
#include "AppliesScript.inc"
#include "ArmorShapesScript.inc"
#include "SashScript.inc"
#include "MountScript.inc"
#include "CostumeAttrScript.inc"
#include "PendantScript.inc"
#include "RingsScript.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)
{
@@ -1066,6 +1090,9 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
{
install_script_module("mt_autohunt", c_szAutoHuntScript);
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_uiscale", c_szUIScaleOptionScript);
install_script_module("mt_inventory", c_szInventoryPagesScript);
@@ -1093,6 +1120,14 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
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_applies", c_szAppliesScript);
install_script_module("mt_shapes", c_szArmorShapesScript);
install_script_module("mt_sash", c_szSashScript);
install_script_module("mt_mount", c_szMountScript);
install_script_module("mt_costume_attr", c_szCostumeAttrScript);
install_script_module("mt_pendant", c_szPendantScript);
install_script_module("mt_rings", c_szRingsScript);
install_script_module("mt_item_actions", c_szItemActionsScript);
if (g_touch_input)
install_script_module("mt_mobile_hud", c_szMobileHUDScript);
}
@@ -1197,6 +1232,10 @@ void Stop()
" getattr(_mm.mouseController, 'cursorDict', {}).clear()\n");
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_game_singletons.reset();
g_game_application.reset();
@@ -159,6 +159,8 @@ class RegisterDialog(ui.BoardWithTitleBar):
ui.__mem_func__(self.Close))
def Destroy(self):
if self.pending:
net.CancelRegisterAccount()
self.Hide()
for row in (self.loginRow, self.passwordRow, self.confirmRow, self.socialRow):
row.Destroy()
@@ -178,6 +180,11 @@ class RegisterDialog(ui.BoardWithTitleBar):
self.loginRow.edit.SetFocus()
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):
row.edit.KillFocus()
self.Hide()
+139
View File
@@ -0,0 +1,139 @@
// PORT: AVA2 new rings (ENABLE_NEW_RINGS), as module mt_rings. Rings are ITEM_TYPE_RINGS (36) worn in
// c_Equipment_Rings1/2 (server WEAR_RINGS_1/2, metin2-server port_rings.cpp). They are socket accessories with
// their own attribute pool, reached through the 40250 switchers / adders. This script adds the two slots to the
// inventory's equipment panel (the free spots beside the shoes on equipment_base, where AVA2 has them too), gives
// rings the accessory tooltip of AVA2 uitooltip.py (limits, applies, attributes, jewel sockets) and marks them as
// drop targets of the drill / jewel / switcher / adder items the server accepts.
static const char c_szRingsScript[] = R"PY(# -*- coding: utf-8 -*-
import item
import player
import uiInventory
import uiToolTip
import ui
# EquipmentSlot coordinates (the shoes are at 39,145).
SLOTS=((item.EQUIPMENT_RINGS1,(2,145)),(item.EQUIPMENT_RINGS2,(75,145)))
# AVA2 CItem::CanPutInto: ring family (vnum / 10 * 10) -> jewel.
JEWEL={19220:50634,19500:50635,19520:50636,19540:50637,19560:50638}
ACCESSORY_SOCKET_MAX_SIZE=3
def IsRing(vnum):
if not vnum:return False
item.SelectItem(vnum)
return item.GetItemType()==item.ITEM_TYPE_RINGS
def MaterialVnum(vnum):
return JEWEL.get(vnum/10*10,0)
def FindChild(node,name):
if node.get("name")==name:return node
for child in node.get("children",()):
found=FindChild(child,name)
if found:return found
return None
def PatchInventoryWindow(window):
base=FindChild(window,"Equipment_Base");slot=FindChild(window,"EquipmentSlot")
if not base or not slot:return
frames=[];cells=[]
for i,(index,(x,y)) in enumerate(SLOTS):
frames.append({"name":"RingSlotBase%d"%i,"type":"image","x":slot["x"]+x,"y":slot["y"]+y,"image":"d:/ymir work/ui/public/Slot_Base.sub"})
cells.append({"index":index,"x":x,"y":y,"width":32,"height":32})
base["children"]=tuple(frames)+tuple(base.get("children",()))
slot["slot"]=tuple(slot["slot"])+tuple(cells)
_execfile=ui.execfile
def ExecFile(fileName,dict):
_execfile(fileName,dict)
name=fileName.replace("\\","/").split("/")[-1].lower()
if name=="inventorywindow.py" and "window" in dict:PatchInventoryWindow(dict["window"])
ui.execfile=ExecFile
_refreshEquip=uiInventory.InventoryWindow.RefreshEquipSlotWindow
def RefreshEquipSlotWindow(self):
_refreshEquip(self)
for index,_ in SLOTS:self.wndEquip.SetItemSlot(index,player.GetItemIndex(index),0)
self.wndEquip.RefreshSlot()
uiInventory.InventoryWindow.RefreshEquipSlotWindow=RefreshEquipSlotWindow
def AttrCount(slot):
return len([1 for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM) if player.GetItemAttribute(slot,i)[0]])
# 40250's __CanPutAccessorySocket / __CanAddAccessorySocket / __CanChangeItemAttrList / __CanAddItemAttr only take
# weapons, armor and the armor accessories; everything else (refine scrolls, ...) keeps the 40250 checks.
RING_USE_TYPES=("USE_ADD_ACCESSORY_SOCKET","USE_PUT_INTO_ACCESSORY_SOCKET","USE_CHANGE_ATTRIBUTE","USE_ADD_ATTRIBUTE","USE_ADD_ATTRIBUTE2")
def CanUseOnRing(srcItemVNum,dstSlotPos,useType):
dstItemVNum=player.GetItemIndex(dstSlotPos)
# the server refuses these items on a worn item ("Fix" in CHARACTER::UseItemEx)
if player.IsEquipmentSlot(dstSlotPos):return False
cur=player.GetItemMetinSocket(dstSlotPos,0);end=player.GetItemMetinSocket(dstSlotPos,1)
if useType=="USE_ADD_ACCESSORY_SOCKET":return end<ACCESSORY_SOCKET_MAX_SIZE
if useType=="USE_PUT_INTO_ACCESSORY_SOCKET":return srcItemVNum==MaterialVnum(dstItemVNum) and cur<end
# 71151 / 76023 / 71152 / 76024 are for weapons / armor
if srcItemVNum in (71151,76023,71152,76024):return False
count=AttrCount(dstSlotPos)
if useType=="USE_CHANGE_ATTRIBUTE":return count>0
if useType=="USE_ADD_ATTRIBUTE":return count<4
if useType=="USE_ADD_ATTRIBUTE2":return count==4
return False
_canUse=uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem
def CanUseSrcItemToDstItem(self,srcItemVNum,srcSlotPos,dstSlotPos):
useType=item.GetUseType(srcItemVNum)
if srcSlotPos!=dstSlotPos and useType in RING_USE_TYPES and IsRing(player.GetItemIndex(dstSlotPos)):
return CanUseOnRing(srcItemVNum,dstSlotPos,useType)
return _canUse(self,srcItemVNum,srcSlotPos,dstSlotPos)
uiInventory.InventoryWindow._InventoryWindow__CanUseSrcItemToDstItem=CanUseSrcItemToDstItem
# --- tooltip (AVA2 uitooltip.py ITEM_TYPE_RINGS branch, without rarity) ---------------------------------------
# A ring falls into the generic branch of ItemToolTip.AddItemData, which only appends the limits; the rest of the
# accessory tooltip follows them.
Tip=uiToolTip.ItemToolTip
_addItemData=Tip.AddItemData
_appendLimit=Tip._ItemToolTip__AppendLimitInformation
def AddItemData(self,itemVnum,metinSlot,attrSlot=0,flags=0,unbindTime=0):
self.mtRing=(itemVnum,metinSlot,attrSlot) if IsRing(itemVnum) else None
try:
return _addItemData(self,itemVnum,metinSlot,attrSlot,flags,unbindTime)
finally:
self.mtRing=None
def AppendLimitInformation(self):
_appendLimit(self)
state=getattr(self,'mtRing',None)
if not state:return
self.mtRing=None
itemVnum,metinSlot,attrSlot=state
item.SelectItem(itemVnum)
self._ItemToolTip__AppendAffectInformation()
self._ItemToolTip__AppendAttributeInformation(attrSlot)
item.SelectItem(itemVnum)
self.AppendWearableInformation()
if metinSlot:
item.SelectItem(itemVnum)
self._ItemToolTip__AppendAccessoryMetinSlotInfo(metinSlot,MaterialVnum(itemVnum))
item.SelectItem(itemVnum)
Tip.AddItemData=AddItemData
Tip._ItemToolTip__AppendLimitInformation=AppendLimitInformation
)PY";
+612
View File
@@ -0,0 +1,612 @@
// PORT: AVA2 sash (披风) windows and tooltips, as module mt_sash. The C++ side is the AVA2 sash module
// (PythonSash.cpp, CG/GC_SASH packets, PART_SASH model + item_scale.txt); this script is AVA2 root/uisash.py
// (CombineWindow/AbsorbWindow verbatim, their uiscript dicts served through ui.execfile), the glue AVA2 put
// into game.py/interfacemodule.py/uiinventory.py (ActSash/AlertSash, right click adds to the open window,
// attached slots lit, costume window sash slot) and uitooltip.py's absorption lines (CalcSashValue,
// SetSashResultItem/SetSashResultAbsItem). Server messages arrive as "MTSASH <code>" commands
// (metin2-server port_sash.cpp). Assets ship in the ava2items pack (tools/server/ava2-items, SASH_*).
static const char c_szSashScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
import chrmgr
import constInfo
import game
import item
import localeInfo
import mouseModule
import player
import sash
import ui
import uiInventory
import uiToolTip
L=player.AutoHuntLocalize
MESSAGES={'already_open':'披风窗口已经打开','absorb_open':'请先关闭披风吸收窗口','combine_open':'请先关闭披风合成窗口',
'locked':'物品已锁定,无法放入','grade':'只能放入相同品级的披风','max_abs':'该披风的吸收率已达上限',
'absorb_type':'只能吸收武器或铠甲','absorbed':'该披风已经吸收过物品','gold':'金币不足',
'success':'成功!','fail':'失败,作为材料的披风已消失'}
ABS_CHANGED='合成完成,吸收率变为 %d%%'
ALERT_COMBINE='合成失败时,作为材料的第二件披风将会消失。'
ALERT_ABSORB='吸收后,被吸收的物品将会消失。'
ABSORB_CHANCE='吸收率:%d%%'
ABSORB_CHANCE2='吸收率:%d%% ~ %d%%'
COMBINE_SCRIPT='''# -*- coding: utf-8 -*-
import player
import uiScriptLocale
L=player.AutoHuntLocalize
window = {
"name" : "Sash_CombineWindow",
"x" : (SCREEN_WIDTH - 215)/2, "y" : (SCREEN_HEIGHT - 270)/2,
"style" : ("movable", "float",),
"width" : 215, "height" : 270,
"children" : (
{
"name" : "board", "type" : "board", "style" : ("attach",),
"x" : 0, "y" : 0, "width" : 215, "height" : 270,
"children" : (
{
"name" : "TitleBar", "type" : "titlebar", "style" : ("attach",),
"x" : 6, "y" : 6, "width" : 200, "color" : "yellow",
"children" : (
{ "name" : "TitleName", "type" : "text", "x" : 95, "y" : 3, "text" : L("披风合成"), "text_horizontal_align" : "center" },
),
},
{
"name" : "Sash_Combine", "type" : "image", "x" : 9, "y" : 35,
"image" : uiScriptLocale.LOCALE_UISCRIPT_PATH + "acce/acce_combine.tga",
"children" : (
{
"name" : "SashSlot", "type" : "slot", "x" : 3, "y" : 3, "width" : 200, "height" : 150,
"slot" : (
{"index":0, "x":78, "y":7, "width":32, "height":32},
{"index":1, "x":78, "y":60, "width":32, "height":32},
{"index":2, "x":78, "y":115, "width":32, "height":32},
),
},
{ "name" : "Main", "type" : "text", "text" : L("主披风"), "text_horizontal_align" : "center", "x" : 85 + 12, "y" : 7 + 36 },
{ "name" : "serve", "type" : "text", "text" : L("材料披风"), "text_horizontal_align" : "center", "x" : 85 + 12, "y" : 60 + 38 },
{ "name" : "Result", "type" : "text", "text" : L("合成结果"), "text_horizontal_align" : "center", "x" : 85 + 12, "y" : 115 + 40 },
),
},
{ "name" : "NeedMoney", "type" : "text", "text" : "", "text_horizontal_align" : "center", "x" : 105, "y" : 215 },
{
"name" : "AcceptButton", "type" : "button", "x" : 40, "y" : 235, "text" : uiScriptLocale.OK,
"default_image" : "d:/ymir work/ui/public/middle_button_01.sub",
"over_image" : "d:/ymir work/ui/public/middle_button_02.sub",
"down_image" : "d:/ymir work/ui/public/middle_button_03.sub",
},
{
"name" : "CancelButton", "type" : "button", "x" : 114, "y" : 235, "text" : uiScriptLocale.CANCEL,
"default_image" : "d:/ymir work/ui/public/middle_button_01.sub",
"over_image" : "d:/ymir work/ui/public/middle_button_02.sub",
"down_image" : "d:/ymir work/ui/public/middle_button_03.sub",
},
),
},
),
}
'''
ABSORB_SCRIPT='''# -*- coding: utf-8 -*-
import player
import uiScriptLocale
L=player.AutoHuntLocalize
window = {
"name" : "Sash_AbsorbtionWindow",
"x" : (SCREEN_WIDTH - 205)/2, "y" : (SCREEN_HEIGHT - 270)/2,
"style" : ("movable", "float",),
"width" : 205, "height" : 270,
"children" : (
{
"name" : "board", "type" : "board", "style" : ("attach",),
"x" : 0, "y" : 0, "width" : 205, "height" : 270,
"children" : (
{
"name" : "TitleBar", "type" : "titlebar", "style" : ("attach",),
"x" : 6, "y" : 6, "width" : 190, "color" : "yellow",
"children" : (
{ "name" : "TitleName", "type" : "text", "x" : 95, "y" : 3, "text" : L("披风吸收"), "text_horizontal_align" : "center" },
),
},
{
"name" : "Sash_Combine", "type" : "image", "x" : 9, "y" : 35,
"image" : uiScriptLocale.LOCALE_UISCRIPT_PATH + "acce/acce_absorb.tga",
"children" : (
{
"name" : "SashSlot", "type" : "slot", "x" : 3, "y" : 3, "width" : 190, "height" : 200,
"slot" : (
{"index":0, "x":26, "y":41, "width":31, "height":31},
{"index":1, "x":125, "y":8, "width":31, "height":96},
{"index":2, "x":75, "y":126, "width":31, "height":31},
),
},
),
},
{
"name" : "AcceptButton", "type" : "button", "x" : 40, "y" : 235, "text" : uiScriptLocale.OK,
"default_image" : "d:/ymir work/ui/public/middle_button_01.sub",
"over_image" : "d:/ymir work/ui/public/middle_button_02.sub",
"down_image" : "d:/ymir work/ui/public/middle_button_03.sub",
},
{
"name" : "CancelButton", "type" : "button", "x" : 114, "y" : 235, "text" : uiScriptLocale.CANCEL,
"default_image" : "d:/ymir work/ui/public/middle_button_01.sub",
"over_image" : "d:/ymir work/ui/public/middle_button_02.sub",
"down_image" : "d:/ymir work/ui/public/middle_button_03.sub",
},
),
},
),
}
'''
SCRIPTS={'sash_combinewindow.py':COMBINE_SCRIPT,'sash_absorbwindow.py':ABSORB_SCRIPT}
# Costume window: the sash slot left of the hair slot (CostumeSlot coordinates).
COSTUME_SASH_POS=(13,8)
g_windows=None
g_effects=False
def CalcSashValue(value,abs):
# AVA2 uitooltip.CalcSashValue; the server scales applies the same way (port_sash.cpp).
if not value:return 0
valueCalc=round(value*abs)/100
valueCalc+=0.5
valueCalc=int(valueCalc)+1 if valueCalc>0 else int(valueCalc)
return 1 if (valueCalc<=0 and value>0) else valueCalc
def IsSash(itemVnum):
item.SelectItem(itemVnum)
return item.GetItemType()==item.ITEM_TYPE_COSTUME and item.GetItemSubType()==item.COSTUME_TYPE_SASH
def IsSashWindowOpen():
return bool(g_windows) and (g_windows[0].IsOpened() or g_windows[1].IsOpened())
class CombineWindow(ui.ScriptWindow):
def __init__(self):
ui.ScriptWindow.__init__(self)
self.isLoaded=0;self.PositionOut=0;self.PositionStartX=0;self.PositionStartY=0
self.tooltipItem=None
def __del__(self):
ui.ScriptWindow.__del__(self)
def Destroy(self):
self.ClearDictionary()
self.titleBar=None;self.btnAccept=None;self.btnCancel=None;self.sashSlot=None;self.needMoney=None;self.Result=None
self.tooltipItem=None;self.PositionOut=0;self.PositionStartX=0;self.PositionStartY=0
def LoadWindow(self):
if self.isLoaded:return
self.isLoaded=1
try:
pyScrLoader=ui.PythonScriptLoader()
pyScrLoader.LoadScriptFile(self,"uiscript/sash_combinewindow.py")
except:
import exception
exception.Abort("Sash_CombineWindow.LoadDialog.LoadScript")
try:
self.board=self.GetChild("board")
self.titleBar=self.GetChild("TitleBar")
self.btnAccept=self.GetChild("AcceptButton")
self.btnCancel=self.GetChild("CancelButton")
self.needMoney=self.GetChild("NeedMoney")
self.Result=self.GetChild("Result")
self.sashSlot=self.GetChild("SashSlot")
except:
import exception
exception.Abort("Sash_CombineWindow.LoadDialog.BindObject")
self.sashSlot.SetSelectEmptySlotEvent(ui.__mem_func__(self.OnSelectEmptySlot))
self.sashSlot.SetUnselectItemSlotEvent(ui.__mem_func__(self.OnSelectItemSlot))
self.sashSlot.SetUseSlotEvent(ui.__mem_func__(self.OnSelectItemSlot))
self.sashSlot.SetOverInItemEvent(ui.__mem_func__(self.OnOverInItem))
self.sashSlot.SetOverOutItemEvent(ui.__mem_func__(self.OnOverOutItem))
self.titleBar.SetCloseEvent(ui.__mem_func__(self.OnClose))
self.btnCancel.SetEvent(ui.__mem_func__(self.OnClose))
self.btnAccept.SetEvent(ui.__mem_func__(self.OnAccept))
def SetItemToolTip(self,itemTooltip):
self.tooltipItem=itemTooltip
def IsOpened(self):
return bool(self.IsShow() and self.isLoaded)
def SetPrice(self):
self.needMoney.SetText(L('合成费用:')+localeInfo.NumberToMoneyString(sash.GetPrice()))
def Open(self):
self.PositionOut=0
(self.PositionStartX,self.PositionStartY,z)=player.GetMainCharacterPosition()
self.SetPrice()
for i in xrange(sash.WINDOW_MAX_MATERIALS+1):
self.sashSlot.ClearSlot(i)
self.Show()
def Close(self):
if self.tooltipItem:self.tooltipItem.HideToolTip()
self.Hide()
def OnClose(self):
sash.SendCloseRequest()
def OnPressEscapeKey(self):
self.OnClose()
return True
def OnAccept(self):
sash.SendRefineRequest()
def OnUpdate(self):
LIMIT_RANGE=sash.LIMIT_RANGE
(x,y,z)=player.GetMainCharacterPosition()
if abs(x-self.PositionStartX)>=LIMIT_RANGE or abs(y-self.PositionStartY)>=LIMIT_RANGE:
if not self.PositionOut:
self.PositionOut+=1
self.OnClose()
def OnSelectEmptySlot(self,selectedSlotPos):
isAttached=mouseModule.mouseController.isAttached()
if not isAttached or selectedSlotPos==sash.WINDOW_MAX_MATERIALS:return
attachedSlotType=mouseModule.mouseController.GetAttachedType()
attachedSlotPos=mouseModule.mouseController.GetAttachedSlotNumber()
attachedInvenType=player.SlotTypeToInvenType(attachedSlotType)
mouseModule.mouseController.DeattachObject()
if attachedSlotType==player.SLOT_TYPE_INVENTORY and attachedInvenType==player.INVENTORY:
sash.Add(attachedInvenType,attachedSlotPos,selectedSlotPos)
def OnSelectItemSlot(self,selectedSlotPos):
if selectedSlotPos==sash.WINDOW_MAX_MATERIALS:return
mouseModule.mouseController.DeattachObject()
sash.Remove(selectedSlotPos)
def OnOverInItem(self,selectedSlotPos):
if self.tooltipItem:
if selectedSlotPos==sash.WINDOW_MAX_MATERIALS:
(isHere,iCell)=sash.GetAttachedItem(0)
if isHere:self.tooltipItem.SetSashResultItem(iCell)
else:
(isHere,iCell)=sash.GetAttachedItem(selectedSlotPos)
if isHere:self.tooltipItem.SetInventoryItem(iCell)
def OnOverOutItem(self):
if self.tooltipItem:self.tooltipItem.HideToolTip()
def Refresh(self,iAct):
self.SetPrice()
self.sashSlot.ClearSlot(sash.WINDOW_MAX_MATERIALS)
for i in xrange(sash.WINDOW_MAX_MATERIALS):
self.sashSlot.ClearSlot(i)
(isHere,iCell)=sash.GetAttachedItem(i)
if isHere:
self.sashSlot.SetItemSlot(i,player.GetItemIndex(iCell),0)
if i==int(sash.WINDOW_MAX_MATERIALS-1):
(itemVnum,MinAbs,MaxAbs)=sash.GetResultItem()
if not itemVnum:break
self.sashSlot.SetItemSlot(i+1,itemVnum,0)
break
self.sashSlot.RefreshSlot()
class AbsorbWindow(CombineWindow):
# AVA2 AbsorbWindow: CombineWindow without the price line, a two-item result tooltip.
def LoadWindow(self):
if self.isLoaded:return
self.isLoaded=1
try:
pyScrLoader=ui.PythonScriptLoader()
pyScrLoader.LoadScriptFile(self,"uiscript/sash_absorbwindow.py")
except:
import exception
exception.Abort("Sash_AbsorbtionWindow.LoadDialog.LoadScript")
try:
self.titleBar=self.GetChild("TitleBar")
self.btnAccept=self.GetChild("AcceptButton")
self.btnCancel=self.GetChild("CancelButton")
self.sashSlot=self.GetChild("SashSlot")
except:
import exception
exception.Abort("Sash_AbsorbtionWindow.LoadDialog.BindObject")
self.sashSlot.SetSelectEmptySlotEvent(ui.__mem_func__(self.OnSelectEmptySlot))
self.sashSlot.SetUnselectItemSlotEvent(ui.__mem_func__(self.OnSelectItemSlot))
self.sashSlot.SetUseSlotEvent(ui.__mem_func__(self.OnSelectItemSlot))
self.sashSlot.SetOverInItemEvent(ui.__mem_func__(self.OnOverInItem))
self.sashSlot.SetOverOutItemEvent(ui.__mem_func__(self.OnOverOutItem))
self.titleBar.SetCloseEvent(ui.__mem_func__(self.OnClose))
self.btnCancel.SetEvent(ui.__mem_func__(self.OnClose))
self.btnAccept.SetEvent(ui.__mem_func__(self.OnAccept))
def SetPrice(self):
pass
def OnOverInItem(self,selectedSlotPos):
if self.tooltipItem:
if selectedSlotPos==sash.WINDOW_MAX_MATERIALS:
(isHere1,iCell1)=sash.GetAttachedItem(0)
(isHere2,iCell2)=sash.GetAttachedItem(1)
if isHere1 and isHere2:self.tooltipItem.SetSashResultAbsItem(iCell1,iCell2)
else:
(isHere,iCell)=sash.GetAttachedItem(selectedSlotPos)
if isHere:self.tooltipItem.SetInventoryItem(iCell)
# --- tooltip (AVA2 uitooltip.py ENABLE_SASH_SYSTEM parts) -------------------------------------------------
Tip=uiToolTip.ItemToolTip
_addItemData=Tip.AddItemData
_appendAffect=Tip._ItemToolTip__AppendAffectInformation
_appendAttribute=Tip._ItemToolTip__AppendAttributeInformation
def AppendAbsorbedItem(self,itemAbsorbedVnum,absChance):
# Attack/defence and applies of the absorbed item, scaled by the absorption rate.
item.SelectItem(itemAbsorbedVnum)
itemType=item.GetItemType()
if itemType==item.ITEM_TYPE_WEAPON:
attack=[item.GetValue(3),item.GetValue(4),item.GetValue(5)]
magic=[item.GetValue(1),item.GetValue(2),item.GetValue(5)]
lines=[]
minPower,maxPower,addPower=[CalcSashValue(v,absChance) for v in attack]
if maxPower>minPower:lines.append(localeInfo.TOOLTIP_ITEM_ATT_POWER%(minPower+addPower,maxPower+addPower))
else:lines.append(localeInfo.TOOLTIP_ITEM_ATT_POWER_ONE_ARG%(minPower+addPower))
minMagic,maxMagic,addPower=[CalcSashValue(v,absChance) for v in magic]
if magic[0]>0 or magic[1]>0:
if maxMagic>minMagic:magicLine=localeInfo.TOOLTIP_ITEM_MAGIC_ATT_POWER%(minMagic+addPower,maxMagic+addPower)
else:magicLine=localeInfo.TOOLTIP_ITEM_MAGIC_ATT_POWER_ONE_ARG%(minMagic+addPower)
if item.GetItemSubType()==item.WEAPON_FAN:lines.insert(0,magicLine)
else:lines.append(magicLine)
for line in lines:self.AppendTextLine(line,self.POSITIVE_COLOR)
elif itemType==item.ITEM_TYPE_ARMOR:
defGrade=CalcSashValue(item.GetValue(1),absChance)
defBonus=CalcSashValue(item.GetValue(5)*2,absChance)
if defGrade>0:
self.AppendSpace(5)
self.AppendTextLine(localeInfo.TOOLTIP_ITEM_DEF_GRADE%(defGrade+defBonus),self.GetChangeTextLineColor(defGrade))
magicDefencePower=CalcSashValue(item.GetValue(0),absChance)
if magicDefencePower>0:
self.AppendTextLine(localeInfo.TOOLTIP_ITEM_MAGIC_DEF_POWER%magicDefencePower,self.GetChangeTextLineColor(magicDefencePower))
for i in xrange(item.ITEM_APPLY_MAX_NUM):
item.SelectItem(itemAbsorbedVnum)
(affectType,affectValue)=item.GetAffect(i)
affectValue=CalcSashValue(affectValue,absChance)
affectString=self._ItemToolTip__GetAffectString(affectType,affectValue)
if affectString and affectValue>0:
self.AppendTextLine(affectString,self.GetChangeTextLineColor(affectValue))
def AppendScaledAttributes(self,attrSlot,absChance):
if not attrSlot:return
for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM):
type,value=attrSlot[i][0],attrSlot[i][1]
if not value:continue
value=CalcSashValue(value,absChance)
affectString=self._ItemToolTip__GetAffectString(type,value)
if affectString and value>0:
self.AppendTextLine(affectString,self._ItemToolTip__GetAttributeColor(i,value))
def AddItemData(self,itemVnum,metinSlot,attrSlot=0,flags=0,unbindTime=0):
self.mtSash=None
if metinSlot and IsSash(itemVnum):
self.mtSash=(itemVnum,int(metinSlot[sash.ABSORPTION_SOCKET]),int(metinSlot[sash.ABSORBED_SOCKET]))
try:
return _addItemData(self,itemVnum,metinSlot,attrSlot,flags,unbindTime)
finally:
self.mtSash=None
def AppendAffectInformation(self):
_appendAffect(self)
state=getattr(self,'mtSash',None)
if not state:return
itemVnum,absChance,itemAbsorbedVnum=state
self.AppendTextLine(L(ABSORB_CHANCE%absChance),self.CONDITION_COLOR)
if itemAbsorbedVnum:AppendAbsorbedItem(self,itemAbsorbedVnum,absChance)
item.SelectItem(itemVnum)
def AppendAttributeInformation(self,attrSlot):
state=getattr(self,'mtSash',None)
if not state:return _appendAttribute(self,attrSlot)
AppendScaledAttributes(self,attrSlot,state[1])
item.SelectItem(state[0])
def SetSashResultItem(self,slotIndex,window_type=player.INVENTORY):
(itemVnum,MinAbs,MaxAbs)=sash.GetResultItem()
if not itemVnum:return
self.ClearToolTip()
metinSlot=[player.GetItemMetinSocket(window_type,slotIndex,i) for i in xrange(player.METIN_SOCKET_MAX_NUM)]
attrSlot=[player.GetItemAttribute(window_type,slotIndex,i) for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM)]
if not IsSash(itemVnum):return
itemDesc=item.GetItemDescription()
self._ItemToolTip__AdjustMaxWidth(attrSlot,itemDesc)
self._ItemToolTip__SetItemTitle(itemVnum,metinSlot,attrSlot)
self.AppendDescription(itemDesc,26)
self.AppendDescription(item.GetItemSummary(),26,self.CONDITION_COLOR)
self._ItemToolTip__AppendLimitInformation()
if MinAbs==MaxAbs:self.AppendTextLine(L(ABSORB_CHANCE%MinAbs),self.CONDITION_COLOR)
else:self.AppendTextLine(L(ABSORB_CHANCE2%(MinAbs,MaxAbs)),self.CONDITION_COLOR)
itemAbsorbedVnum=int(metinSlot[sash.ABSORBED_SOCKET])
if itemAbsorbedVnum:AppendAbsorbedItem(self,itemAbsorbedVnum,MaxAbs)
AppendScaledAttributes(self,attrSlot,MaxAbs)
item.SelectItem(itemVnum)
self.AppendWearableInformation()
self.ShowToolTip()
def SetSashResultAbsItem(self,slotIndex1,slotIndex2,window_type=player.INVENTORY):
itemVnumSash=player.GetItemIndex(window_type,slotIndex1)
itemVnumTarget=player.GetItemIndex(window_type,slotIndex2)
if not itemVnumSash or not itemVnumTarget:return
self.ClearToolTip()
if not IsSash(itemVnumSash):return
metinSlot=[player.GetItemMetinSocket(window_type,slotIndex1,i) for i in xrange(player.METIN_SOCKET_MAX_NUM)]
attrSlot=[player.GetItemAttribute(window_type,slotIndex2,i) for i in xrange(player.ATTRIBUTE_SLOT_MAX_NUM)]
absChance=int(metinSlot[sash.ABSORPTION_SOCKET])
itemDesc=item.GetItemDescription()
self._ItemToolTip__AdjustMaxWidth(attrSlot,itemDesc)
self._ItemToolTip__SetItemTitle(itemVnumSash,metinSlot,attrSlot)
self.AppendDescription(itemDesc,26)
self.AppendDescription(item.GetItemSummary(),26,self.CONDITION_COLOR)
item.SelectItem(itemVnumSash)
self._ItemToolTip__AppendLimitInformation()
self.AppendTextLine(L(ABSORB_CHANCE%absChance),self.CONDITION_COLOR)
AppendAbsorbedItem(self,itemVnumTarget,absChance)
AppendScaledAttributes(self,attrSlot,absChance)
item.SelectItem(itemVnumSash)
self.AppendWearableInformation()
self.ShowToolTip()
Tip.AddItemData=AddItemData
Tip._ItemToolTip__AppendAffectInformation=AppendAffectInformation
Tip._ItemToolTip__AppendAttributeInformation=AppendAttributeInformation
Tip.SetSashResultItem=SetSashResultItem
Tip.SetSashResultAbsItem=SetSashResultAbsItem
# --- uiscripts: the two sash windows and the costume window's sash slot ------------------------------------
def FindChild(node,name):
if node.get("name")==name:return node
for child in node.get("children",()):
found=FindChild(child,name)
if found:return found
return None
def PatchCostumeWindow(window):
base=FindChild(window,"Costume_Base");slot=FindChild(window,"CostumeSlot")
if not base or not slot:return
x,y=COSTUME_SASH_POS
frame={"name":"SashSlotBase","type":"image","x":slot["x"]+x,"y":slot["y"]+y,"image":"d:/ymir work/ui/public/Slot_Base.sub"}
base["children"]=(frame,)+tuple(base.get("children",()))
slot["slot"]=tuple(slot["slot"])+({"index":item.COSTUME_SLOT_SASH,"x":x,"y":y,"width":32,"height":32},)
_execfile=ui.execfile
def ExecFile(fileName,dict):
name=fileName.replace("\\","/").split("/")[-1].lower()
if name in SCRIPTS:
exec(SCRIPTS[name],dict)
return
_execfile(fileName,dict)
if name=="costumewindow.py" and "window" in dict:PatchCostumeWindow(dict["window"])
ui.execfile=ExecFile
_refreshCostume=uiInventory.CostumeWindow.RefreshCostumeSlot
def RefreshCostumeSlot(self):
_refreshCostume(self)
self.wndEquip.SetItemSlot(item.COSTUME_SLOT_SASH,player.GetItemIndex(item.COSTUME_SLOT_SASH),0)
self.wndEquip.RefreshSlot()
uiInventory.CostumeWindow.RefreshCostumeSlot=RefreshCostumeSlot
# --- inventory: right click adds to the open sash window, attached items are lit ----------------------------
_useItemSlot=uiInventory.InventoryWindow.UseItemSlot
_refreshBag=uiInventory.InventoryWindow.RefreshBagSlotWindow
def UseItemSlot(self,slotIndex):
if IsSashWindowOpen() and app.GetCursor()!=app.SELL:
cell=self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(slotIndex)
if not player.IsEquipmentSlot(cell):
sash.Add(player.INVENTORY,cell,255)
return
return _useItemSlot(self,slotIndex)
def AutoPotionActive(cell):
return constInfo.IS_AUTO_POTION(player.GetItemIndex(cell)) and player.GetItemMetinSocket(cell,0)!=0
def RefreshBagSlotWindow(self):
_refreshBag(self)
attached=set()
if IsSashWindowOpen():
for j in xrange(sash.WINDOW_MAX_MATERIALS):
(isHere,iCell)=sash.GetAttachedItem(j)
if isHere:attached.add(iCell)
lit=getattr(self,'mtSashLit',set())
now=set()
for i in xrange(player.INVENTORY_PAGE_SIZE):
cell=self._InventoryWindow__InventoryLocalSlotPosToGlobalSlotPos(i)
if cell in attached:
self.wndItem.ActivateSlot(i);now.add(i)
elif i in lit and not AutoPotionActive(cell):
self.wndItem.DeactivateSlot(i)
self.mtSashLit=now
self.wndItem.RefreshSlot()
uiInventory.InventoryWindow.UseItemSlot=UseItemSlot
uiInventory.InventoryWindow.RefreshBagSlotWindow=RefreshBagSlotWindow
# --- game window: ActSash/AlertSash (called by the GC_SASH packet), MTSASH messages, effects -----------------
def RegisterEffects():
global g_effects
if g_effects:return
g_effects=True
chrmgr.RegisterEffect(chrmgr.EFFECT_SASH_SUCCEDED,"","d:/ymir work/effect/etc/buff/buff_item6.mse")
chrmgr.RegisterEffect(chrmgr.EFFECT_SASH_EQUIP,"","d:/ymir work/effect/etc/buff/buff_item7.mse")
chrmgr.RegisterEffect(chrmgr.EFFECT_REFINED+chrmgr.EFFECT_SASH,"Bip01","d:/ymir work/pc/common/effect/armor/acc_01.mse")
_open=game.GameWindow.Open
_close=game.GameWindow.Close
_command=game.GameWindow.BINARY_ServerCommand_Run
def Open(self):
global g_windows
_open(self)
RegisterEffects()
g_windows=(CombineWindow(),AbsorbWindow())
for window in g_windows:
window.LoadWindow();window.Hide()
if self.interface:window.SetItemToolTip(self.interface.tooltipItem)
def Close(self):
global g_windows
if g_windows:
for window in g_windows:window.Close();window.Destroy()
g_windows=None
_close(self)
def ActSash(self,iAct,bWindow):
if not g_windows or not self.interface:return
window=g_windows[0 if bWindow else 1]
inventory=self.interface.wndInventory
if iAct==1:
if not window.IsOpened():window.Open()
if inventory and not inventory.IsShow():inventory.Show()
elif iAct==2:
if window.IsOpened():window.Close()
elif iAct==3 or iAct==4:
if window.IsOpened():window.Refresh(iAct)
if inventory:inventory.RefreshBagSlotWindow()
def AlertSash(self,bWindow):
self.PopupMessage(L(ALERT_COMBINE if bWindow else ALERT_ABSORB))
def Command(self,line):
if line.startswith('MTSASH '):
words=line.split()
if len(words)==3 and words[1]=='abs' and words[2].isdigit():text=ABS_CHANGED%int(words[2])
else:text=MESSAGES.get(words[1] if len(words)>1 else '')
if text:chat.AppendChat(chat.CHAT_TYPE_INFO,L(text))
return 1
return _command(self,line)
game.GameWindow.Open=Open
game.GameWindow.Close=Close
game.GameWindow.ActSash=ActSash
game.GameWindow.AlertSash=AlertSash
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";
+5 -3
View File
@@ -31,7 +31,7 @@ REASONS={'running':'洗练中','matched':'已获得目标属性','limit':'已达
ERRORS={'rate':'操作过快,请稍后','invalid':'参数无效','item':'物品不存在','equipped':'请先卸下装备或结束交易','no_attr':'该物品不能洗练',
'scroll':'没有可用的洗练道具','target':'目标属性超出可洗范围','running':'已有洗练在进行','busy':'当前状态不能洗练',
'unsupported':'服务器尚未启用自动洗练','slot':'该条属性不能锁定','max':'最多锁定 2 条,且至少留 1 条可洗',
'db':'锁定保存失败,请稍后再试'}
'db':'锁定保存失败,请稍后再试','five_attr':'装备需要 5 条普通属性才能锁定','material':'锁定需要道具 50348,解锁需要道具 50349'}
def Number(text,low=0,high=4294967295):
@@ -188,9 +188,11 @@ class SwitchbotWindow(ui.Window):
vnum=player.GetItemIndex(player.INVENTORY,cell)
if not vnum:continue
item.SelectItem(vnum);kind=item.GetItemType()
if kind==item.ITEM_TYPE_USE and item.GetItemSubType()==USE_CHANGE_ATTRIBUTE:
# 54001-54267 with a value2 mask are AVA2 costume enchants (mt_costume_attr), 71302 / 53996 talisman
# rerolls (mt_pendant) and 50348 / 50349 attribute lock materials, not switchers; talismans have their own attribute pool.
if kind==item.ITEM_TYPE_USE and item.GetItemSubType()==USE_CHANGE_ATTRIBUTE and not (54001<=vnum<=54267 and item.GetValue(2)) and vnum not in (71302,53996,50348,50349):
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 not (kind==item.ITEM_TYPE_ARMOR and item.GetItemSubType()==item.ARMOR_PENDANT) and any(player.GetItemAttribute(player.INVENTORY,cell,i)[0] for i in range(5)):
items.append((cell,vnum))
selected=self.Selected();scroll=self.Scroll();state=self.bot.state
if state and state['active'] and (state['cell'],state['vnum']) in items:
@@ -760,6 +760,12 @@ bool LoadLocaleData(const char* localePath)
Tracenf("LoadLocaleData - LoadItemDesc(%s) Error", szItemDesc);
}
// PORT: AVA2 sash part scale (ENABLE_SASH_SYSTEM)
char szItemScale[256];
snprintf(szItemScale, sizeof(szItemScale), "%s/item_scale.txt", localePath);
if (!rkItemMgr.LoadItemScale(szItemScale))
Tracenf("LoadLocaleData: error while loading %s.", szItemScale);
if (!rkNPCMgr.LoadNonPlayerData(szMobProto))
{
TraceError("LoadLocaleData - LoadMobProto(%s) Error", szMobProto);
+13
View File
@@ -19,6 +19,9 @@ if(NOT TARGET mtpython)
endif()
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})
# 40250 system headers the ported sources keep including verbatim: D3D8/D3DX/Miles SDK types everywhere,
# 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)
add_test(NAME port.boss
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
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()
# Offline locale data builder: retains 40250 proto layout and gameplay fields.
add_executable(locale_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/locale_proto_patch.cpp)
target_link_libraries(locale_proto_patch PRIVATE port_platform)
add_executable(biolog_proto_patch ${PROJECT_SOURCE_DIR}/tests/port/biolog_proto_patch.cpp)
target_link_libraries(biolog_proto_patch PRIVATE port_platform)
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).
add_executable(eterpack_pack ${PROJECT_SOURCE_DIR}/tests/port/eterpack_pack.cpp)
target_link_libraries(eterpack_pack PRIVATE port_platform)
+44 -10
View File
@@ -685,21 +685,50 @@ void CGraphicThingInstance::DeformAll()
{
m_bUpdated = true;
CGrannyLODController::FDeformAll deform;
deform.mc_pWorldMatrix = &m_worldMatrix;
std::for_each(m_LODControllerVector.begin(), m_LODControllerVector.end(), deform);
D3DXMATRIX matScaled;
for (int i = 0; i < (int)m_LODControllerVector.size(); ++i)
{
CGrannyLODController* pkLOD = m_LODControllerVector[i];
if (pkLOD->isModelInstance())
pkLOD->DeformAll(__GetPartWorldMatrix(i, matScaled));
}
}
void CGraphicThingInstance::SetPartScale(int iPart, float fX, float fY, float fZ)
{
D3DXMATRIX matScale;
D3DXMatrixScaling(&matScale, fX, fY, fZ);
m_partScaleMap[iPart] = matScale;
}
void CGraphicThingInstance::ClearPartScale(int iPart)
{
m_partScaleMap.erase(iPart);
}
const D3DXMATRIX * CGraphicThingInstance::__GetPartWorldMatrix(int iPart, D3DXMATRIX & rScaled) const
{
if (m_partScaleMap.empty())
return &m_worldMatrix;
std::map<int, D3DXMATRIX>::const_iterator it = m_partScaleMap.find(iPart);
if (it == m_partScaleMap.end())
return &m_worldMatrix;
D3DXMatrixMultiply(&rScaled, &it->second, &m_worldMatrix);
return &rScaled;
}
void CGraphicThingInstance::DeformNoSkin()
{
m_bUpdated = true;
std::vector<CGrannyLODController*>::iterator i;
for (i=m_LODControllerVector.begin(); i!=m_LODControllerVector.end(); ++i)
D3DXMATRIX matScaled;
for (int i = 0; i < (int)m_LODControllerVector.size(); ++i)
{
CGrannyLODController* pkLOD=*i;
CGrannyLODController* pkLOD=m_LODControllerVector[i];
if (pkLOD->isModelInstance())
pkLOD->DeformNoSkin(&m_worldMatrix);
pkLOD->DeformNoSkin(__GetPartWorldMatrix(i, matScaled));
}
}
@@ -707,9 +736,13 @@ void CGraphicThingInstance::OnDeform()
{
m_bUpdated = true;
CGrannyLODController::FDeform deform;
deform.mc_pWorldMatrix = &m_worldMatrix;
std::for_each(m_LODControllerVector.begin(), m_LODControllerVector.end(), deform);
D3DXMATRIX matScaled;
for (int i = 0; i < (int)m_LODControllerVector.size(); ++i)
{
CGrannyLODController* pkLOD = m_LODControllerVector[i];
if (pkLOD->isModelInstance())
pkLOD->Deform(__GetPartWorldMatrix(i, matScaled));
}
}
void CGraphicThingInstance::__SetLocalTime(float fLocalTime)
@@ -938,6 +971,7 @@ void CGraphicThingInstance::OnClear()
{
stl_wipe(m_LODControllerVector);
stl_wipe_second(m_roMotionThingMap);
m_partScaleMap.clear();
for (DWORD d = 0; d < m_modelThingSetVector.size(); ++d)
m_modelThingSetVector[d].Clear();
+8
View File
@@ -34,6 +34,11 @@ class CGraphicThingInstance : public CGraphicObjectInstance
void UpdateLODLevel();
void UpdateTime();
// PORT: AVA2 ENABLE_OBJ_SCALLING for the sash part: the part is deformed with Scale * world,
// i.e. scaled about the actor origin in actor space (item_scale.txt).
void SetPartScale(int iPart, float fX, float fY, float fZ);
void ClearPartScale(int iPart);
void DeformAll(); // 모든 LOD 디폼
bool LessRenderOrder(CGraphicThingInstance* pkThingInst);
@@ -139,6 +144,9 @@ class CGraphicThingInstance : public CGraphicObjectInstance
std::vector<CGrannyLODController *> m_LODControllerVector;
std::vector<TModelThingSet> m_modelThingSetVector;
std::map<DWORD, CGraphicThing::TRef *> m_roMotionThingMap;
std::map<int, D3DXMATRIX> m_partScaleMap; // PORT: AVA2 sash scale
const D3DXMATRIX * __GetPartWorldMatrix(int iPart, D3DXMATRIX & rScaled) const;
protected:
virtual void OnUpdateCollisionData(const CStaticCollisionDataVector * pscdVector);
+6
View File
@@ -32,9 +32,15 @@ void CNetworkAddress::Clear()
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')
return false;
return true;
#endif
}
bool CNetworkAddress::Set(const char* c_szAddr, int port)
+21
View File
@@ -1,5 +1,8 @@
#include "StdAfx.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()
{
@@ -16,6 +19,9 @@ void CNetworkDatagram::Destroy()
if (INVALID_SOCKET==m_sock)
return;
#ifdef MT_HAS_MESHPORT
mt::meshport::forget_socket(m_sock);
#endif
closesocket(m_sock);
__Initialize();
@@ -68,6 +74,9 @@ void CNetworkDatagram::Update()
bool CNetworkDatagram::CanRecv()
{
#ifdef MT_HAS_MESHPORT
if (mt::meshport::datagram_ready(m_sock)) return true;
#endif
if (FD_ISSET(m_sock, &m_fdsRecv))
return true;
@@ -77,19 +86,31 @@ bool CNetworkDatagram::CanRecv()
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);
return recvfrom(m_sock, (char*)pvBuf, uBufLen, MSG_PEEK, (PSOCKADDR)pkSockAddrIn, &nSockAddrInLen);
#endif
}
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);
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)
{
#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));
#endif
}
+19
View File
@@ -1,13 +1,20 @@
#include "StdAfx.h"
#include "NetDatagramReceiver.h"
#ifdef MT_HAS_MESHPORT
#include "transport.h" // PORT: shared datagram receive and relay lifetime.
#endif
BOOL CNetDatagramReceiver::Process()
{
m_recvBufCurrentPos = 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);
m_recvBufCurrentSize = recvfrom(m_Socket, (char *)m_recvBuf, m_recvBufSize, 0, (PSOCKADDR)&m_SockAddr, &irecvAddrLength);
#endif
if (m_recvBufCurrentSize <= 0)
{
@@ -42,7 +49,9 @@ BOOL CNetDatagramReceiver::isBind()
BOOL CNetDatagramReceiver::Bind(DWORD /*dwAddress*/, WORD wPortIndex)
{
Clear(); // PORT: rebinding releases any previous UDP/mesh association.
m_Socket = socket(AF_INET, SOCK_DGRAM, 0);
if (m_Socket == INVALID_SOCKET) return FALSE;
DWORD arg = 1;
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)
{
Tracef("Failed binding socket\n");
closesocket(m_Socket);
m_Socket = INVALID_SOCKET;
return FALSE;
}
@@ -79,6 +90,12 @@ void CNetDatagramReceiver::SetRecvBufferSize(int recvBufSize)
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;
@@ -93,6 +110,7 @@ void CNetDatagramReceiver::Clear()
CNetDatagramReceiver::CNetDatagramReceiver()
{
m_isBind = FALSE;
m_recvBuf = NULL;
m_recvBufSize = 0;
@@ -100,6 +118,7 @@ CNetDatagramReceiver::CNetDatagramReceiver()
}
CNetDatagramReceiver::~CNetDatagramReceiver()
{
Clear();
if (m_recvBuf)
delete [] m_recvBuf;
}
+20
View File
@@ -1,5 +1,8 @@
#include "StdAfx.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)
{
@@ -8,12 +11,19 @@ BOOL CNetDatagramSender::SetSocket(const char * c_szIP, 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_dwAddress = dwAddress;
m_wPortIndex = wPortIndex;
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));
m_SockAddr.sin_family = AF_INET;
@@ -27,7 +37,11 @@ BOOL CNetDatagramSender::Send(const void * pBuffer, int iSize)
{
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));
#endif
if (iSendingLength < 0)
{
Tracef("Failed sending packet\n");
@@ -55,4 +69,10 @@ 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.
}
}
+28 -1
View File
@@ -1,5 +1,8 @@
#include "StdAfx.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"
#ifndef _IMPROVED_PACKET_ENCRYPTION_
@@ -288,6 +291,19 @@ void CNetworkStream::Process()
if (m_sock == INVALID_SOCKET)
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 fdsSend;
@@ -373,6 +389,9 @@ void CNetworkStream::Clear()
m_cipher.CleanUp();
#endif
#ifdef MT_HAS_MESHPORT
mt::meshport::forget_socket(m_sock); // PORT: discard partial SOCKS negotiation before fd reuse.
#endif
closesocket(m_sock);
m_sock = INVALID_SOCKET;
@@ -416,7 +435,12 @@ bool CNetworkStream::Connect(const CNetworkAddress& c_rkNetAddr, int limitSec)
DWORD arg = 1;
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();
@@ -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;
return true;
}
+1
View File
@@ -243,6 +243,7 @@ class CActorInstance : public IActorInstance, public IFlyTargetableObject
bool SetRace(DWORD eRace);
void SetHair(DWORD eHair);
void AttachSash(CItemData * pItemData, float fSpecular = 0.0f); // PORT: AVA2 sash
void SetVirtualID(DWORD dwVID);
void SetShape(DWORD eShape, float fSpecular=0.0f);
+41
View File
@@ -624,3 +624,44 @@ void CActorInstance::__ClearAttachingEffect()
}
m_AttachingEffectList.clear();
}
// PORT: AVA2 ActorInstanceAttach.cpp AttachSash (ENABLE_SASH_SYSTEM). The sash is a model part on
// "Bip01 Spine2" that plays its own first motion (the cloth sway).
void CActorInstance::AttachSash(CItemData * pItemData, float fSpecular)
{
if (!pItemData)
{
RegisterModelThing(CRaceData::PART_SASH, NULL);
SetModelInstance(CRaceData::PART_SASH, CRaceData::PART_SASH, 0);
RefreshActorInstance();
return;
}
RegisterModelThing(CRaceData::PART_SASH, pItemData->GetModelThing());
SetModelInstance(CRaceData::PART_SASH, CRaceData::PART_SASH, 0);
AttachModelInstance(CRaceData::PART_MAIN, "Bip01 Spine2", CRaceData::PART_SASH);
if (fSpecular > 0.0f)
{
SMaterialData kMaterialData;
kMaterialData.pImage = NULL;
kMaterialData.isSpecularEnable = TRUE;
kMaterialData.fSpecularPower = fSpecular;
kMaterialData.bSphereMapIndex = 1;
SetMaterialData(CRaceData::PART_SASH, NULL, kMaterialData);
}
if (CRaceData::PART_SASH >= m_LODControllerVector.size())
return;
if (CGrannyLODController* pLODController = m_LODControllerVector[CRaceData::PART_SASH])
{
if (CGrannyModelInstance* pSashModelInstance = pLODController->GetModelInstance())
{
CGraphicThing* pItemGraphicThing = pItemData->GetModelThing();
if (pItemGraphicThing && pItemGraphicThing->GetMotionCount() > 0)
if (CGrannyMotion* pItemMotion = pItemGraphicThing->GetMotionPointer(0))
pSashModelInstance->SetMotionPointer(pItemMotion);
}
}
}
+38
View File
@@ -215,6 +215,43 @@ void CItemData::SetItemTableData(TItemTable * pItemTable)
memcpy(&m_ItemTable, pItemTable, sizeof(TItemTable));
}
// PORT: AVA2 ItemData.cpp SetItemScale/GetItemScale. The position columns are dropped: AVA2 feeds
// them to SetScalePosition, which its deform never reads. JOB_WOLFMAN rows are skipped (no wolfman).
void CItemData::SetItemScale(const std::string & strJob, const std::string & strSex, const std::string & strScaleX, const std::string & strScaleY, const std::string & strScaleZ)
{
DWORD dwPos;
if (strJob == "JOB_WARRIOR")
dwPos = NRaceData::JOB_WARRIOR;
else if (strJob == "JOB_ASSASSIN")
dwPos = NRaceData::JOB_ASSASSIN;
else if (strJob == "JOB_SURA")
dwPos = NRaceData::JOB_SURA;
else if (strJob == "JOB_SHAMAN")
dwPos = NRaceData::JOB_SHAMAN;
else
return;
dwPos += 1;
if (strSex == "F")
dwPos += 5;
m_aScaleInfo[dwPos].bSet = true;
m_aScaleInfo[dwPos].fScaleX = float(atof(strScaleX.c_str()) / 100.0f);
m_aScaleInfo[dwPos].fScaleY = float(atof(strScaleY.c_str()) / 100.0f);
m_aScaleInfo[dwPos].fScaleZ = float(atof(strScaleZ.c_str()) / 100.0f);
}
bool CItemData::GetItemScale(DWORD dwPos, float & fScaleX, float & fScaleY, float & fScaleZ)
{
if (dwPos >= sizeof(m_aScaleInfo) / sizeof(m_aScaleInfo[0]) || !m_aScaleInfo[dwPos].bSet)
return false;
fScaleX = m_aScaleInfo[dwPos].fScaleX;
fScaleY = m_aScaleInfo[dwPos].fScaleY;
fScaleZ = m_aScaleInfo[dwPos].fScaleZ;
return true;
}
const CItemData::TItemTable* CItemData::GetTable() const
{
return &m_ItemTable;
@@ -452,6 +489,7 @@ void CItemData::Clear()
m_pLODModelThingVector.clear();
memset(&m_ItemTable, 0, sizeof(m_ItemTable));
memset(m_aScaleInfo, 0, sizeof(m_aScaleInfo));
}
CItemData::CItemData()
+25
View File
@@ -56,6 +56,8 @@ class CItemData
ITEM_TYPE_SECONDARY_COIN, //32 명도전.
ITEM_TYPE_RING, //33 반지 (유니크 슬롯이 아닌 순수 반지 슬롯)
ITEM_TYPE_BELT, //34 벨트
ITEM_TYPE_SHINING, //35 PORT: AVA2 shining (not ported, keeps the AVA2 numbering)
ITEM_TYPE_RINGS, //36 PORT: AVA2 new rings, worn in c_Equipment_Rings1/2
ITEM_TYPE_MAX_NUM,
};
@@ -95,6 +97,7 @@ class CItemData
ARMOR_FOOTS,
ARMOR_NECK,
ARMOR_EAR,
ARMOR_PENDANT, // PORT: AVA2 talisman, worn in c_Equipment_Pendant
ARMOR_NUM_TYPES
};
@@ -102,6 +105,9 @@ class CItemData
{
COSTUME_BODY, //0 갑옷(main look)
COSTUME_HAIR, //1 헤어(탈착가능)
COSTUME_MOUNT = 2, // PORT: AVA2 mount costume (value0 = horse race)
COSTUME_SASH = 3, // PORT: AVA2 sash
COSTUME_MOUNT_SKIN = 4, // PORT: AVA2 mount skin (needs a worn COSTUME_MOUNT)
COSTUME_NUM_TYPES,
};
@@ -350,6 +356,16 @@ class CItemData
APPLY_ANTI_CRITICAL_PCT, //90 크리티컬 저항
APPLY_ANTI_PENETRATE_PCT, //91 관통타격 저항
// PORT AVA2 bonus numbering, matching the server (common/length.h): 92-122 and 129-138 are not ported yet.
APPLY_ENCHANT_ELEC = 123, // talisman: attack +x% on monsters of that element
APPLY_ENCHANT_FIRE = 124,
APPLY_ENCHANT_ICE = 125,
APPLY_ENCHANT_WIND = 126,
APPLY_ENCHANT_EARTH = 127,
APPLY_ENCHANT_DARK = 128,
APPLY_ATTBONUS_STONE = 139,
APPLY_ATTBONUS_BOSS = 140,
MAX_APPLY_NUM, //
};
@@ -491,6 +507,9 @@ class CItemData
//BOOL LoadItemData(const char * c_szFileName);
void SetDefaultItemData(const char * c_szIconFileName, const char * c_szModelFileName = NULL);
void SetItemTableData(TItemTable * pItemTable);
// PORT: AVA2 item_scale.txt (sash part scale per job/sex). Slot = job + 1, + 5 when female.
void SetItemScale(const std::string & strJob, const std::string & strSex, const std::string & strScaleX, const std::string & strScaleY, const std::string & strScaleZ);
bool GetItemScale(DWORD dwPos, float & fScaleX, float & fScaleY, float & fScaleZ);
protected:
void __LoadFiles();
@@ -514,6 +533,12 @@ class CItemData
NRaceData::TAttachingDataVector m_AttachingDataVector;
DWORD m_dwVnum;
TItemTable m_ItemTable;
struct SScaleInfo
{
bool bSet;
float fScaleX, fScaleY, fScaleZ;
};
SScaleInfo m_aScaleInfo[11];
public:
static void DestroySystem();
+36
View File
@@ -402,3 +402,39 @@ CItemManager::~CItemManager()
{
Destroy();
}
// PORT: AVA2 ItemManager.cpp LoadItemScale (ENABLE_SASH_SYSTEM). Each row covers vnum .. vnum + 4;
// unlike AVA2 (MakeItemData) only vnums already in the item table are touched.
bool CItemManager::LoadItemScale(const char * c_szFileName)
{
const VOID* pvData;
CMappedFile kFile;
if (!CEterPackManager::Instance().Get(kFile, c_szFileName, &pvData))
return false;
CMemoryTextFileLoader kTextFileLoader;
kTextFileLoader.Bind(kFile.Size(), pvData);
CTokenVector kTokenVector;
for (DWORD i = 0; i < kTextFileLoader.GetLineCount(); ++i)
{
if (!kTextFileLoader.SplitLineByTab(i, &kTokenVector))
continue;
if (kTokenVector.size() < ITEMSCALE_NUM)
{
TraceError("LoadItemScale: invalid line %d (%s).", i, c_szFileName);
continue;
}
for (int j = 0; j < 5; ++j)
{
TItemMap::iterator f = m_ItemMap.find(atoi(kTokenVector[ITEMSCALE_VNUM].c_str()) + j);
if (m_ItemMap.end() == f)
continue;
f->second->SetItemScale(kTokenVector[ITEMSCALE_JOB], kTokenVector[ITEMSCALE_SEX], kTokenVector[ITEMSCALE_SCALE_X], kTokenVector[ITEMSCALE_SCALE_Y], kTokenVector[ITEMSCALE_SCALE_Z]);
}
}
return true;
}
+15
View File
@@ -13,6 +13,20 @@ class CItemManager : public CSingleton<CItemManager>
ITEMDESC_COL_SUMM,
ITEMDESC_COL_NUM,
};
// PORT: AVA2 item_scale.txt columns
enum EItemScaleColumn
{
ITEMSCALE_VNUM,
ITEMSCALE_JOB,
ITEMSCALE_SEX,
ITEMSCALE_SCALE_X,
ITEMSCALE_SCALE_Y,
ITEMSCALE_SCALE_Z,
ITEMSCALE_POSITION_X,
ITEMSCALE_POSITION_Y,
ITEMSCALE_POSITION_Z,
ITEMSCALE_NUM,
};
public:
typedef std::map<DWORD, CItemData*> TItemMap;
@@ -31,6 +45,7 @@ class CItemManager : public CSingleton<CItemManager>
/////
bool LoadItemDesc(const char* c_szFileName);
bool LoadItemScale(const char* c_szFileName); // PORT: AVA2 sash
bool LoadItemList(const char* c_szFileName);
bool LoadItemTable(const char* c_szFileName);
CItemData * MakeItemData(DWORD dwIndex);
+1
View File
@@ -23,6 +23,7 @@ class CRaceData
PART_HEAD,
PART_WEAPON_LEFT,
PART_HAIR,
PART_SASH, // PORT: AVA2 sash ("Bip01 Spine2", item vnum - 85000)
PART_MAX_NUM,
};
+11
View File
@@ -22,6 +22,15 @@ bool __IsNPCRace(unsigned race)
return false;
}
// PORT: AVA2 __IsNewMountRace — mount costume / skin races (christmas_2017_sleddog, ...) live in npc_mount.
bool __IsNewMountRace(unsigned race)
{
if (race >= 32000 && race <= 32100)
return true;
return false;
}
void __GetRaceResourcePathes(unsigned race, std::vector <std::string>& vec_stPathes)
{
if (__IsGuildRace(race))
@@ -37,6 +46,8 @@ void __GetRaceResourcePathes(unsigned race, std::vector <std::string>& vec_stPat
if (race >= 30000)
{
vec_stPathes.push_back ("d:/ymir work/npc2/");
if (__IsNewMountRace(race)) // PORT: AVA2
vec_stPathes.push_back ("d:/ymir work/npc_mount/");
vec_stPathes.push_back ("d:/ymir work/npc/");
vec_stPathes.push_back ("d:/ymir work/monster/");
vec_stPathes.push_back ("d:/ymir work/monster2/");
+6
View File
@@ -104,7 +104,13 @@ enum EDragonSoulStepTypes
const DWORD c_Costume_Slot_Hair = c_Costume_Slot_Start + 1;
const DWORD c_Costume_Slot_Count = 2;
const DWORD c_Costume_Slot_End = c_Costume_Slot_Start + c_Costume_Slot_Count;
const DWORD c_Costume_Slot_Sash = c_Equipment_Start + 24; // PORT: AVA2 sash, server WEAR_COSTUME_SASH (after WEAR_BELT)
const DWORD c_Costume_Slot_Mount = c_Equipment_Start + 25; // PORT: AVA2 mount costume, server WEAR_COSTUME_MOUNT
const DWORD c_Costume_Slot_MountSkin = c_Equipment_Start + 26; // PORT: AVA2 mount skin, server WEAR_COSTUME_MOUNT_SKIN
#endif
const DWORD c_Equipment_Pendant = c_Equipment_Start + 27; // PORT: AVA2 talisman, server WEAR_PENDANT
const DWORD c_Equipment_Rings1 = c_Equipment_Start + 28; // PORT: AVA2 new rings, server WEAR_RINGS_1/2
const DWORD c_Equipment_Rings2 = c_Equipment_Start + 29;
// [주의] 숫자(32) 하드코딩 주의. 현재 서버에서 용혼석 슬롯은 32부터임.
+92 -1
View File
@@ -756,6 +756,7 @@ bool CInstanceBase::Create(const SCreateData& c_rkCreateData)
{
SetHair(c_rkCreateData.m_dwHair);
SetWeapon(c_rkCreateData.m_dwWeapon);
SetSash(c_rkCreateData.m_dwSash); // PORT: AVA2 sash
}
__Create_SetName(c_rkCreateData);
@@ -2472,11 +2473,24 @@ void CInstanceBase::__UpdateEffectShown()
else
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()
{
if (!PORT_IsDisplayVisible())
return false;
if (!__IsInViewFrustum())
return false;
if (IsAffect(AFFECT_INVISIBILITY))
@@ -2592,6 +2606,80 @@ void CInstanceBase::SetHair(DWORD eHair)
m_GraphicThingInstance.SetHair(eHair);
}
// PORT: AVA2 InstanceBase.cpp SetSash/ChangeSash/ClearSashEffect (ENABLE_SASH_SYSTEM + ENABLE_OBJ_SCALLING).
// Differences: part 0 detaches the model (AVA2 looked up item 85000 and returned early, leaving the old
// sash on), and only the item_scale.txt scale is applied (its position columns never reached the deform).
void CInstanceBase::SetSash(DWORD dwSash)
{
if (!IsPC() && GetRace() > 8)
return;
if (IsPC() && IsPoly())
return;
m_awPart[CRaceData::PART_SASH] = dwSash;
ClearSashEffect();
m_GraphicThingInstance.ClearPartScale(CRaceData::PART_SASH);
if (!dwSash)
{
m_GraphicThingInstance.AttachSash(NULL);
return;
}
dwSash += 85000;
float fSpecular = 65.0f;
if (dwSash > 86000)
{
fSpecular += 35;
m_dwSashEffect = EFFECT_REFINED + EFFECT_SASH;
__EffectContainer_AttachEffect(m_dwSashEffect);
}
fSpecular /= 100.0f;
CItemData * pItemData;
if (!CItemManager::Instance().GetItemDataPointer(dwSash, &pItemData))
{
m_GraphicThingInstance.AttachSash(NULL);
return;
}
m_GraphicThingInstance.AttachSash(pItemData, fSpecular);
DWORD dwRace = GetRace(), dwPos = RaceToJob(dwRace), dwSex = RaceToSex(dwRace);
dwPos += 1;
if (dwSex == 0)
dwPos += 5;
float fScaleX, fScaleY, fScaleZ;
if (pItemData->GetItemScale(dwPos, fScaleX, fScaleY, fScaleZ))
m_GraphicThingInstance.SetPartScale(CRaceData::PART_SASH, fScaleX, fScaleY, fScaleZ);
}
void CInstanceBase::ChangeSash(DWORD dwSash)
{
if (!IsPC() && GetRace() > 7)
return;
if (GetPart(CRaceData::PART_SASH) == dwSash)
return;
SetSash(dwSash);
}
void CInstanceBase::ClearSashEffect()
{
if (!m_dwSashEffect)
return;
__EffectContainer_DetachEffect(m_dwSashEffect);
m_dwSashEffect = 0;
}
void CInstanceBase::ChangeHair(DWORD eHair)
{
if (!HAIR_COLOR_ENABLE)
@@ -2815,6 +2903,7 @@ bool CInstanceBase::ChangeArmor(DWORD dwArmor)
DWORD dwVID = GetVirtualID();
DWORD dwRace = GetRace();
DWORD eHair = GetPart(CRaceData::PART_HAIR);
DWORD dwSash = GetPart(CRaceData::PART_SASH); // PORT: AVA2 sash
DWORD eWeapon = GetPart(CRaceData::PART_WEAPON);
float fRot = GetRotation();
float fAdvRot = GetAdvancingRotation();
@@ -2835,6 +2924,7 @@ bool CInstanceBase::ChangeArmor(DWORD dwArmor)
SetArmor(dwArmor);
SetHair(eHair);
SetSash(dwSash); // PORT: AVA2 sash
SetWeapon(eWeapon);
SetRotation(fRot);
@@ -3102,6 +3192,7 @@ void CInstanceBase::__Initialize()
m_swordRefineEffectRight = 0;
m_swordRefineEffectLeft = 0;
m_armorRefineEffect = 0;
m_dwSashEffect = 0; // PORT: AVA2 sash
m_sAlignment = 0;
m_byPKMode = 0;
+9
View File
@@ -26,6 +26,7 @@ class CInstanceBase
DWORD m_dwWeapon;
DWORD m_dwHair;
DWORD m_dwMountVnum;
DWORD m_dwSash; // PORT: AVA2 sash part (item vnum - 85000)
short m_sAlignment;
BYTE m_byPKMode;
@@ -293,6 +294,7 @@ class CInstanceBase
EFFECT_BODYARMOR_SPECIAL, // 갑옷 4-2-1
EFFECT_BODYARMOR_SPECIAL2, // 갑옷 4-2-2
EFFECT_SASH, // PORT: AVA2 refined sash glow (acc_01.mse)
EFFECT_REFINED_NUM,
};
@@ -370,6 +372,8 @@ class CInstanceBase
EFFECT_HALLOWEEN_CANDY_EQUIP, // 할로윈 사탕 착용 순간에 발동하는 이펙트
EFFECT_HAPPINESS_RING_EQUIP, // 행복의 반지 착용 순간에 발동하는 이펙트
EFFECT_LOVE_PENDANT_EQUIP, // 행복의 반지 착용 순간에 발동하는 이펙트
EFFECT_SASH_SUCCEDED, // PORT: AVA2 sash
EFFECT_SASH_EQUIP,
EFFECT_TEMP,
EFFECT_NUM,
};
@@ -398,6 +402,7 @@ class CInstanceBase
public:
void SetMainInstance();
bool PORT_IsDisplayVisible();
void OnSelected();
void OnUnselected();
@@ -500,6 +505,8 @@ class CInstanceBase
bool ChangeArmor(DWORD dwArmor);
void ChangeWeapon(DWORD eWeapon);
void ChangeHair(DWORD eHair);
void SetSash(DWORD dwSash); // PORT: AVA2 sash
void ChangeSash(DWORD dwSash);
void ChangeGuild(DWORD dwGuildID);
DWORD GetWeaponType();
@@ -909,6 +916,7 @@ class CInstanceBase
UINT __GetRefinedEffect(CItemData* pItem);
void __ClearWeaponRefineEffect();
void __ClearArmorRefineEffect();
void ClearSashEffect(); // PORT: AVA2 sash
protected:
void __AttachSelectEffect();
@@ -977,6 +985,7 @@ class CInstanceBase
DWORD m_swordRefineEffectRight;
DWORD m_swordRefineEffectLeft;
DWORD m_armorRefineEffect;
DWORD m_dwSashEffect; // PORT: AVA2 sash
struct SMoveAfterFunc
{
@@ -77,6 +77,7 @@ void SNetworkActorData::__copy__(const SNetworkActorData& src)
m_dwArmor = src.m_dwArmor;
m_dwWeapon = src.m_dwWeapon;
m_dwHair = src.m_dwHair;
m_dwSash = src.m_dwSash; // PORT: AVA2 sash
m_dwOwnerVID = src.m_dwOwnerVID;
@@ -104,6 +105,7 @@ SNetworkActorData::SNetworkActorData()
m_dwArmor=0;
m_dwWeapon=0;
m_dwHair=0;
m_dwSash=0; // PORT: AVA2 sash
m_dwEmpireID=0;
m_dwOwnerVID=0;
@@ -356,6 +358,7 @@ CInstanceBase* CNetworkActorManager::__AppendCharacterManagerActor(SNetworkActor
kCreateData.m_dwArmor=rkNetActorData.m_dwArmor;
kCreateData.m_dwWeapon=rkNetActorData.m_dwWeapon;
kCreateData.m_dwHair=rkNetActorData.m_dwHair;
kCreateData.m_dwSash=rkNetActorData.m_dwSash; // PORT: AVA2 sash
kCreateData.m_isMain=__IsMainActorVID(dwVID);
CInstanceBase* pOldInstance = rkChrMgr.GetInstancePtr(dwVID);
@@ -473,6 +476,7 @@ void CNetworkActorManager::UpdateActor(const SNetworkUpdateActorData& c_rkNetUpd
pkInstFind->ChangeArmor(c_rkNetUpdateActorData.m_dwArmor);
pkInstFind->ChangeWeapon(c_rkNetUpdateActorData.m_dwWeapon);
pkInstFind->ChangeHair(c_rkNetUpdateActorData.m_dwHair);
pkInstFind->ChangeSash(c_rkNetUpdateActorData.m_dwSash); // PORT: AVA2 sash
pkInstFind->ChangeGuild(c_rkNetUpdateActorData.m_dwGuildID);
pkInstFind->SetAffectFlagContainer(c_rkNetUpdateActorData.m_kAffectFlags);
pkInstFind->SetMoveSpeed(c_rkNetUpdateActorData.m_dwMovSpd);
@@ -502,6 +506,7 @@ void CNetworkActorManager::UpdateActor(const SNetworkUpdateActorData& c_rkNetUpd
rkNetActorData.m_dwArmor=c_rkNetUpdateActorData.m_dwArmor;
rkNetActorData.m_dwWeapon=c_rkNetUpdateActorData.m_dwWeapon;
rkNetActorData.m_dwHair=c_rkNetUpdateActorData.m_dwHair;
rkNetActorData.m_dwSash=c_rkNetUpdateActorData.m_dwSash; // PORT: AVA2 sash
rkNetActorData.m_sAlignment=c_rkNetUpdateActorData.m_sAlignment;
rkNetActorData.m_byPKMode=c_rkNetUpdateActorData.m_byPKMode;
}
@@ -31,6 +31,7 @@ struct SNetworkActorData
DWORD m_dwArmor;
DWORD m_dwWeapon;
DWORD m_dwHair;
DWORD m_dwSash; // PORT: AVA2 sash
DWORD m_dwOwnerVID;
@@ -85,6 +86,7 @@ struct SNetworkUpdateActorData
DWORD m_dwArmor;
DWORD m_dwWeapon;
DWORD m_dwHair;
DWORD m_dwSash; // PORT: AVA2 sash
DWORD m_dwMovSpd;
DWORD m_dwAtkSpd;
short m_sAlignment;
@@ -100,6 +102,7 @@ struct SNetworkUpdateActorData
m_dwArmor=0;
m_dwWeapon=0;
m_dwHair=0;
m_dwSash=0;
m_dwMovSpd=0;
m_dwAtkSpd=0;
m_sAlignment=0;
+53
View File
@@ -154,6 +154,7 @@ enum
HEADER_CG_DRAGON_SOUL_REFINE = 205,
HEADER_CG_STATE_CHECKER = 206,
HEADER_CG_SASH = 211, // PORT: AVA2 sash (metin2-server FEATURE-AVA2-UNLOCK P4)
#ifdef __AUCTION__
HEADER_CG_AUCTION_CMD = 205,
@@ -358,6 +359,7 @@ enum
HEADER_GC_SPECIFIC_EFFECT = 208,
HEADER_GC_DRAGON_SOUL_REFINE = 209,
HEADER_GC_RESPOND_CHANNELSTATUS = 210,
HEADER_GC_SASH = 215, // PORT: AVA2 sash
HEADER_GC_KEY_AGREEMENT_COMPLETED = 0xfa, // _IMPROVED_PACKET_ENCRYPTION_
HEADER_GC_KEY_AGREEMENT = 0xfb, // _IMPROVED_PACKET_ENCRYPTION_
@@ -1285,6 +1287,7 @@ enum ECharacterEquipmentPart
CHR_EQUIPPART_WEAPON,
CHR_EQUIPPART_HEAD,
CHR_EQUIPPART_HAIR,
CHR_EQUIPPART_SASH, // PORT: AVA2 sash, item vnum - 85000
CHR_EQUIPPART_NUM,
};
@@ -2401,6 +2404,8 @@ enum SPECIAL_EFFECT
SE_EQUIP_HALLOWEEN_CANDY, // 할로윈 사탕을 착용(-_-;)한 순간에 발동하는 이펙트
SE_EQUIP_HAPPINESS_RING, // 크리스마스 행복의 반지를 착용하는 순간에 발동하는 이펙트
SE_EQUIP_LOVE_PENDANT, // 발렌타인 사랑의 팬던트(71145) 착용할 때 이펙트 (발동이펙트임, 지속이펙트 아님)
SE_EFFECT_SASH_SUCCEDED, // PORT: AVA2 sash combine success
SE_EFFECT_SASH_EQUIP,
};
typedef struct SPacketGCSpecialEffect
@@ -2812,4 +2817,52 @@ typedef struct SChannelStatus
BYTE bStatus;
} TChannelStatus;
// PORT: AVA2 sash window. HEADER_CG_SASH and HEADER_GC_SASH share the layout; the server sends the
// combine (bWindow true) and absorb windows' state, messages come as "MTSASH <code>" server commands.
enum ESashInfo
{
SASH_ABSORPTION_SOCKET = 0,
SASH_ABSORBED_SOCKET = 1,
SASH_WINDOW_MAX_MATERIALS = 2,
};
enum
{
SASH_SUBHEADER_GC_OPEN = 0,
SASH_SUBHEADER_GC_CLOSE,
SASH_SUBHEADER_GC_ADDED,
SASH_SUBHEADER_GC_REMOVED,
SASH_SUBHEADER_CG_REFINED,
SASH_SUBHEADER_CG_CLOSE = 0,
SASH_SUBHEADER_CG_ADD,
SASH_SUBHEADER_CG_REMOVE,
SASH_SUBHEADER_CG_REFINE,
};
typedef struct SPacketSash
{
BYTE header;
BYTE subheader;
bool bWindow;
DWORD dwPrice;
BYTE bPos;
TItemPos tPos;
DWORD dwItemVnum;
DWORD dwMinAbs;
DWORD dwMaxAbs;
} TPacketSash;
typedef struct SSashMaterial
{
BYTE bHere;
WORD wCell;
} TSashMaterial;
typedef struct SSashResult
{
DWORD dwItemVnum;
DWORD dwMinAbs;
DWORD dwMaxAbs;
} TSashResult;
#pragma pack(pop)
@@ -12,6 +12,10 @@
#include <mutex>
#include <string>
#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 <fcntl.h>
@@ -35,6 +39,11 @@ const int c_iRegisterTimeoutMs = 8000;
std::mutex gs_kRegisterMutex;
std::atomic<int> gs_iRegisterState(REGISTER_IDLE);
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)
{
@@ -58,8 +67,14 @@ std::string __UrlEncode(const std::string& c_rstText)
bool __WaitFor(int iSocket, short sEvents)
{
pollfd kPoll = { iSocket, sEvents, 0 };
return poll(&kPoll, 1, c_iRegisterTimeoutMs) == 1 && (kPoll.revents & sEvents);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(c_iRegisterTimeoutMs);
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)
@@ -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)
return "ERR CONNECT";
if (gs_registerCancelled) { freeaddrinfo(pkAddr); return "ERR CANCELLED"; }
const int iSocket = socket(pkAddr->ai_family, pkAddr->ai_socktype, pkAddr->ai_protocol);
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;
setsockopt(iSocket, SOL_SOCKET, SO_NOSIGPIPE, &iOne, sizeof(iOne));
#endif
{
std::lock_guard<std::mutex> lock(gs_registerSocketMutex);
gs_registerSocket = iSocket;
}
fcntl(iSocket, F_SETFL, fcntl(iSocket, F_GETFL, 0) | O_NONBLOCK);
std::string stResult = "ERR CONNECT";
int iError = 0;
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 ||
#endif
(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 =
"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);
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
}
if (gs_registerWorker.joinable()) gs_registerWorker.join();
gs_registerCancelled = false;
const std::string stHost = szHost;
const std::string stBody = "login=" + __UrlEncode(szLogin) + "&password=" + __UrlEncode(szPassword) +
"&social_id=" + __UrlEncode(szSocialID);
std::thread([stHost, iPort, stBody]()
gs_registerWorker = std::thread([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_iRegisterState = REGISTER_DONE;
}).detach();
});
return Py_BuildValue("i", 1);
}
@@ -196,3 +244,22 @@ PyObject* netGetRegisterAccountResult(PyObject* poSelf, PyObject* poArgs)
std::lock_guard<std::mutex> kLock(gs_kRegisterMutex);
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();
}
@@ -840,5 +840,8 @@ void initchrmgr()
PyModule_AddIntConstant(poModule, "EFFECT_HALLOWEEN_CANDY_EQUIP", CInstanceBase::EFFECT_HALLOWEEN_CANDY_EQUIP);
PyModule_AddIntConstant(poModule, "EFFECT_HAPPINESS_RING_EQUIP", CInstanceBase::EFFECT_HAPPINESS_RING_EQUIP);
PyModule_AddIntConstant(poModule, "EFFECT_LOVE_PENDANT_EQUIP", CInstanceBase::EFFECT_LOVE_PENDANT_EQUIP);
PyModule_AddIntConstant(poModule, "EFFECT_SASH_SUCCEDED", CInstanceBase::EFFECT_SASH_SUCCEDED); // PORT: AVA2 sash
PyModule_AddIntConstant(poModule, "EFFECT_SASH_EQUIP", CInstanceBase::EFFECT_SASH_EQUIP);
PyModule_AddIntConstant(poModule, "EFFECT_SASH", CInstanceBase::EFFECT_SASH); // refined index, EFFECT_REFINED + EFFECT_SASH
}
@@ -84,6 +84,7 @@ PyObject * chrCreateInstance(PyObject* poSelf, PyObject* poArgs)
kCreateData.m_dwArmor=8;
kCreateData.m_dwWeapon=0;
kCreateData.m_dwHair=0;
kCreateData.m_dwSash=0; // PORT: AVA2 sash
kCreateData.m_isMain=false;
PyObject* poHorse=PyDict_GetItemString(poDict, "horse");
@@ -533,6 +534,21 @@ PyObject * chrRefresh(PyObject* poSelf, PyObject* poArgs)
return Py_BuildNone();
}
// PORT: AVA2 PythonCharacterModule.cpp chrSetSash (ENABLE_SASH_SYSTEM)
PyObject * chrSetSash(PyObject* poSelf, PyObject* poArgs)
{
int dwSash;
if (!PyTuple_GetInteger(poArgs, 0, &dwSash))
return Py_BuildException();
CInstanceBase * pkInst = CPythonCharacterManager::Instance().GetSelectedInstancePtr();
if (!pkInst)
return Py_BuildNone();
pkInst->SetSash(dwSash);
return Py_BuildNone();
}
PyObject * chrMountHorse(PyObject* poSelf, PyObject* poArgs)
{
CInstanceBase * pkInst = CPythonCharacterManager::Instance().GetSelectedInstancePtr();
@@ -1211,6 +1227,7 @@ PyObject * chrtestSetRideMan(PyObject* poSelf, PyObject* poArgs)
kCreateData.m_dwRace = 0;
kCreateData.m_dwArmor = 0;
kCreateData.m_dwHair = 100;
kCreateData.m_dwSash = 0; // PORT: AVA2 sash
kCreateData.m_dwMovSpd = 100;
kCreateData.m_dwAtkSpd = 100;
kCreateData.m_dwMountVnum = imount;
@@ -1227,6 +1244,7 @@ void initchr()
{
{ "DismountHorse", chrDismountHorse, METH_VARARGS },
{ "MountHorse", chrMountHorse, METH_VARARGS },
{ "SetSash", chrSetSash, METH_VARARGS }, // PORT: AVA2 sash
{ "Destroy", chrDestroy, METH_VARARGS },
{ "Update", chrUpdate, METH_VARARGS },
@@ -1438,6 +1456,7 @@ void initchr()
PyModule_AddIntConstant(poModule, "PART_WEAPON", CRaceData::PART_WEAPON);
PyModule_AddIntConstant(poModule, "PART_HEAD", CRaceData::PART_HEAD);
PyModule_AddIntConstant(poModule, "PART_WEAPON_LEFT", CRaceData::PART_WEAPON_LEFT);
PyModule_AddIntConstant(poModule, "PART_SASH", CRaceData::PART_SASH); // PORT: AVA2 sash
/////
@@ -640,6 +640,12 @@ void initItem()
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_BODY", c_Costume_Slot_Body);
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_HAIR", c_Costume_Slot_Hair);
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_END", c_Costume_Slot_End);
PyModule_AddIntConstant(poModule, "COSTUME_TYPE_SASH", CItemData::COSTUME_SASH); // PORT: AVA2 sash
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_SASH", c_Costume_Slot_Sash);
PyModule_AddIntConstant(poModule, "COSTUME_TYPE_MOUNT", CItemData::COSTUME_MOUNT); // PORT: AVA2 mount costume
PyModule_AddIntConstant(poModule, "COSTUME_TYPE_MOUNT_SKIN", CItemData::COSTUME_MOUNT_SKIN);
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_MOUNT", c_Costume_Slot_Mount);
PyModule_AddIntConstant(poModule, "COSTUME_SLOT_MOUNT_SKIN", c_Costume_Slot_MountSkin);
#endif
#ifdef ENABLE_NEW_EQUIPMENT_SYSTEM
@@ -754,6 +760,11 @@ void initItem()
PyModule_AddIntConstant(poModule, "ARMOR_FOOTS", CItemData::ARMOR_FOOTS);
PyModule_AddIntConstant(poModule, "ARMOR_NECK", CItemData::ARMOR_NECK);
PyModule_AddIntConstant(poModule, "ARMOR_EAR", CItemData::ARMOR_EAR);
PyModule_AddIntConstant(poModule, "ARMOR_PENDANT", CItemData::ARMOR_PENDANT); // PORT: AVA2 talisman
PyModule_AddIntConstant(poModule, "EQUIPMENT_PENDANT", c_Equipment_Pendant);
PyModule_AddIntConstant(poModule, "ITEM_TYPE_RINGS", CItemData::ITEM_TYPE_RINGS); // PORT: AVA2 new rings
PyModule_AddIntConstant(poModule, "EQUIPMENT_RINGS1", c_Equipment_Rings1);
PyModule_AddIntConstant(poModule, "EQUIPMENT_RINGS2", c_Equipment_Rings2);
PyModule_AddIntConstant(poModule, "ITEM_APPLY_MAX_NUM", CItemData::ITEM_APPLY_MAX_NUM);
PyModule_AddIntConstant(poModule, "ITEM_SOCKET_MAX_NUM", CItemData::ITEM_SOCKET_MAX_NUM);
@@ -852,4 +863,12 @@ void initItem()
PyModule_AddIntConstant(poModule, "APPLY_RESIST_DARK", CItemData::APPLY_RESIST_DARK );
PyModule_AddIntConstant(poModule, "APPLY_ANTI_CRITICAL_PCT", CItemData::APPLY_ANTI_CRITICAL_PCT );
PyModule_AddIntConstant(poModule, "APPLY_ANTI_PENETRATE_PCT", CItemData::APPLY_ANTI_PENETRATE_PCT );
PyModule_AddIntConstant(poModule, "APPLY_ATTBONUS_STONE", CItemData::APPLY_ATTBONUS_STONE );
PyModule_AddIntConstant(poModule, "APPLY_ATTBONUS_BOSS", CItemData::APPLY_ATTBONUS_BOSS );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_ELEC", CItemData::APPLY_ENCHANT_ELEC );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_FIRE", CItemData::APPLY_ENCHANT_FIRE );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_ICE", CItemData::APPLY_ENCHANT_ICE );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_WIND", CItemData::APPLY_ENCHANT_WIND );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_EARTH", CItemData::APPLY_ENCHANT_EARTH );
PyModule_AddIntConstant(poModule, "APPLY_ENCHANT_DARK", CItemData::APPLY_ENCHANT_DARK );
}
@@ -184,6 +184,7 @@ class CMainPacketHeaderMap : public CNetworkPacketHeaderMap
Set(HEADER_GC_HYBRIDCRYPT_SDB, CNetworkPacketHeaderMap::TPacketType(sizeof(TPacketGCHybridSDB), DYNAMIC_SIZE_PACKET));
Set(HEADER_GC_SPECIFIC_EFFECT, CNetworkPacketHeaderMap::TPacketType(sizeof(TPacketGCSpecificEffect), STATIC_SIZE_PACKET));
Set(HEADER_GC_DRAGON_SOUL_REFINE, CNetworkPacketHeaderMap::TPacketType(sizeof(TPacketGCDragonSoulRefine), STATIC_SIZE_PACKET));
Set(HEADER_GC_SASH, CNetworkPacketHeaderMap::TPacketType(sizeof(TPacketSash), STATIC_SIZE_PACKET)); // PORT: AVA2 sash
}
};
@@ -267,6 +267,12 @@ class CPythonNetworkStream : public CNetworkStream, public CSingleton<CPythonNet
// 용홍석 강화
bool SendDragonSoulRefinePacket(BYTE bRefineType, TItemPos* pos);
// PORT: AVA2 sash
bool SendSashClosePacket();
bool SendSashAddPacket(TItemPos tPos, BYTE bPos);
bool SendSashRemovePacket(BYTE bPos);
bool SendSashRefinePacket();
// Handshake
bool RecvHandshakePacket();
bool RecvHandshakeOKPacket();
@@ -545,6 +551,7 @@ class CPythonNetworkStream : public CNetworkStream, public CSingleton<CPythonNet
// 용혼석 관련
bool RecvDragonSoulRefine();
bool RecvSashPacket(bool bReturn = false); // PORT: AVA2 sash
// MiniMap Info
bool RecvNPCList();
@@ -1,4 +1,7 @@
#include "StdAfx.h"
#ifdef MT_HAS_MESHPORT
#include "transport.h" // PORT: cancel only this login's pending mesh transports.
#endif
#include "PythonNetworkStream.h"
//#include "PythonNetworkDatagram.h"
#include "AccountConnector.h"
@@ -14,6 +17,7 @@ std::list<std::string> g_kList_strCommand;
// PORT: PythonAccountRegister.cpp
PyObject* netRegisterAccount(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)
{
@@ -1014,6 +1018,22 @@ PyObject* netLogOutGame(PyObject* poSelf, PyObject* poArgs)
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)
{
CPythonNetworkStream& rkNetStream=CPythonNetworkStream::Instance();
@@ -1774,6 +1794,10 @@ void initnet()
{ "SendChangeNamePacket", netSendChangeNamePacket, METH_VARARGS },
{ "SendCreateCharacterPacket", netSendCreateCharacterPacket, METH_VARARGS },
{ "RegisterAccount", netRegisterAccount, METH_VARARGS },
{ "CancelRegisterAccount", netCancelRegisterAccount, METH_NOARGS },
#ifdef MT_HAS_MESHPORT
{ "CancelPendingLogin", netCancelPendingLogin, METH_NOARGS },
#endif
{ "GetRegisterAccountResult", netGetRegisterAccountResult, METH_VARARGS },
{ "SendDestroyCharacterPacket", netSendDestroyCharacterPacket, METH_VARARGS },
{ "SendEnterGamePacket", netSendEnterGamePacket, METH_VARARGS },
@@ -630,6 +630,10 @@ void CPythonNetworkStream::GamePhase()
ret = RecvDragonSoulRefine();
break;
case HEADER_GC_SASH: // PORT: AVA2 sash
ret = RecvSashPacket();
break;
default:
ret = RecvDefaultPacket(header);
break;
@@ -113,6 +113,7 @@ bool CPythonNetworkStream::RecvCharacterAppendPacket()
kNetActorData.m_dwArmor=0;/*chrAddPacket.awPart[CHR_EQUIPPART_ARMOR]*/;
kNetActorData.m_dwWeapon=0;/*chrAddPacket.awPart[CHR_EQUIPPART_WEAPON]*/;
kNetActorData.m_dwHair=0;/*chrAddPacket.awPart[CHR_EQUIPPART_HAIR]*/;
kNetActorData.m_dwSash=0; // PORT: AVA2 sash
kNetActorData.m_dwMountVnum=0;/*chrAddPacket.dwMountVnum*/;
kNetActorData.m_dwLevel = 0; // 몬스터 레벨 표시 안함
@@ -160,6 +161,7 @@ bool CPythonNetworkStream::RecvCharacterAdditionalInfo()
kNetActorData.m_dwArmor=chrInfoPacket.awPart[CHR_EQUIPPART_ARMOR];
kNetActorData.m_dwWeapon=chrInfoPacket.awPart[CHR_EQUIPPART_WEAPON];
kNetActorData.m_dwHair=chrInfoPacket.awPart[CHR_EQUIPPART_HAIR];
kNetActorData.m_dwSash=chrInfoPacket.awPart[CHR_EQUIPPART_SASH]; // PORT: AVA2 sash
kNetActorData.m_dwMountVnum=chrInfoPacket.dwMountVnum;
__RecvCharacterAppendPacket(&kNetActorData);
@@ -193,6 +195,7 @@ bool CPythonNetworkStream::RecvCharacterAppendPacketNew()
kNetActorData.m_dwArmor=chrAddPacket.awPart[CHR_EQUIPPART_ARMOR];
kNetActorData.m_dwWeapon=chrAddPacket.awPart[CHR_EQUIPPART_WEAPON];
kNetActorData.m_dwHair=chrAddPacket.awPart[CHR_EQUIPPART_HAIR];
kNetActorData.m_dwSash=chrAddPacket.awPart[CHR_EQUIPPART_SASH]; // PORT: AVA2 sash
kNetActorData.m_dwStateFlags=chrAddPacket.bStateFlag;
kNetActorData.m_dwVID=chrAddPacket.dwVID;
kNetActorData.m_dwMountVnum=chrAddPacket.dwMountVnum;
@@ -221,6 +224,7 @@ bool CPythonNetworkStream::RecvCharacterUpdatePacket()
kNetUpdateActorData.m_dwArmor=chrUpdatePacket.awPart[CHR_EQUIPPART_ARMOR];
kNetUpdateActorData.m_dwWeapon=chrUpdatePacket.awPart[CHR_EQUIPPART_WEAPON];
kNetUpdateActorData.m_dwHair=chrUpdatePacket.awPart[CHR_EQUIPPART_HAIR];
kNetUpdateActorData.m_dwSash=chrUpdatePacket.awPart[CHR_EQUIPPART_SASH]; // PORT: AVA2 sash
kNetUpdateActorData.m_dwVID=chrUpdatePacket.dwVID;
kNetUpdateActorData.m_kAffectFlags.CopyData(0, sizeof(chrUpdatePacket.dwAffectFlag[0]), &chrUpdatePacket.dwAffectFlag[0]);
kNetUpdateActorData.m_kAffectFlags.CopyData(32, sizeof(chrUpdatePacket.dwAffectFlag[1]), &chrUpdatePacket.dwAffectFlag[1]);
@@ -246,6 +250,7 @@ bool CPythonNetworkStream::RecvCharacterUpdatePacketNew()
kNetUpdateActorData.m_dwArmor=chrUpdatePacket.awPart[CHR_EQUIPPART_ARMOR];
kNetUpdateActorData.m_dwWeapon=chrUpdatePacket.awPart[CHR_EQUIPPART_WEAPON];
kNetUpdateActorData.m_dwHair=chrUpdatePacket.awPart[CHR_EQUIPPART_HAIR];
kNetUpdateActorData.m_dwSash=chrUpdatePacket.awPart[CHR_EQUIPPART_SASH]; // PORT: AVA2 sash
kNetUpdateActorData.m_dwVID=chrUpdatePacket.dwVID;
kNetUpdateActorData.m_kAffectFlags.CopyData(0, sizeof(chrUpdatePacket.dwAffectFlag[0]), &chrUpdatePacket.dwAffectFlag[0]);
kNetUpdateActorData.m_kAffectFlags.CopyData(32, sizeof(chrUpdatePacket.dwAffectFlag[1]), &chrUpdatePacket.dwAffectFlag[1]);
@@ -858,6 +858,13 @@ bool CPythonNetworkStream::RecvSpecialEffect()
case SE_EQUIP_LOVE_PENDANT:
effect = CInstanceBase::EFFECT_LOVE_PENDANT_EQUIP;
break;
// PORT: AVA2 sash
case SE_EFFECT_SASH_SUCCEDED:
effect = CInstanceBase::EFFECT_SASH_SUCCEDED;
break;
case SE_EFFECT_SASH_EQUIP:
effect = CInstanceBase::EFFECT_SASH_EQUIP;
break;
default:
@@ -0,0 +1,159 @@
#include "StdAfx.h"
#include "PythonNetworkStream.h"
#include "PythonSash.h"
#include "Packet.h"
// PORT: AVA2 PythonNetworkStreamPhaseGame.cpp sash packets (ENABLE_SASH_SYSTEM) verbatim.
bool CPythonNetworkStream::RecvSashPacket(bool bReturn)
{
TPacketSash sPacket;
if (!Recv(sizeof(sPacket), &sPacket))
return bReturn;
bReturn = true;
switch (sPacket.subheader)
{
case SASH_SUBHEADER_GC_OPEN:
{
CPythonSash::Instance().Clear();
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "ActSash", Py_BuildValue("(ib)", 1, sPacket.bWindow));
}
break;
case SASH_SUBHEADER_GC_CLOSE:
{
CPythonSash::Instance().Clear();
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "ActSash", Py_BuildValue("(ib)", 2, sPacket.bWindow));
}
break;
case SASH_SUBHEADER_GC_ADDED:
{
CPythonSash::Instance().AddMaterial(sPacket.dwPrice, sPacket.bPos, sPacket.tPos);
if (sPacket.bPos == 1)
{
CPythonSash::Instance().AddResult(sPacket.dwItemVnum, sPacket.dwMinAbs, sPacket.dwMaxAbs);
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "AlertSash", Py_BuildValue("(b)", sPacket.bWindow));
}
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "ActSash", Py_BuildValue("(ib)", 3, sPacket.bWindow));
}
break;
case SASH_SUBHEADER_GC_REMOVED:
{
CPythonSash::Instance().RemoveMaterial(sPacket.dwPrice, sPacket.bPos);
if (sPacket.bPos == 0)
CPythonSash::Instance().RemoveMaterial(sPacket.dwPrice, 1);
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "ActSash", Py_BuildValue("(ib)", 4, sPacket.bWindow));
}
break;
case SASH_SUBHEADER_CG_REFINED:
{
if (sPacket.dwMaxAbs == 0)
CPythonSash::Instance().RemoveMaterial(sPacket.dwPrice, 1);
else
{
CPythonSash::Instance().RemoveMaterial(sPacket.dwPrice, 0);
CPythonSash::Instance().RemoveMaterial(sPacket.dwPrice, 1);
}
PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], "ActSash", Py_BuildValue("(ib)", 4, sPacket.bWindow));
}
break;
default:
TraceError("CPythonNetworkStream::RecvSashPacket: unknown subheader %d\n.", sPacket.subheader);
break;
}
return bReturn;
}
bool CPythonNetworkStream::SendSashClosePacket()
{
if (!__CanActMainInstance())
return true;
TItemPos tPos;
tPos.window_type = INVENTORY;
tPos.cell = 0;
TPacketSash sPacket;
sPacket.header = HEADER_CG_SASH;
sPacket.subheader = SASH_SUBHEADER_CG_CLOSE;
sPacket.dwPrice = 0;
sPacket.bPos = 0;
sPacket.tPos = tPos;
sPacket.dwItemVnum = 0;
sPacket.dwMinAbs = 0;
sPacket.dwMaxAbs = 0;
if (!Send(sizeof(sPacket), &sPacket))
return false;
return SendSequence();
}
bool CPythonNetworkStream::SendSashAddPacket(TItemPos tPos, BYTE bPos)
{
if (!__CanActMainInstance())
return true;
TPacketSash sPacket;
sPacket.header = HEADER_CG_SASH;
sPacket.subheader = SASH_SUBHEADER_CG_ADD;
sPacket.dwPrice = 0;
sPacket.bPos = bPos;
sPacket.tPos = tPos;
sPacket.dwItemVnum = 0;
sPacket.dwMinAbs = 0;
sPacket.dwMaxAbs = 0;
if (!Send(sizeof(sPacket), &sPacket))
return false;
return SendSequence();
}
bool CPythonNetworkStream::SendSashRemovePacket(BYTE bPos)
{
if (!__CanActMainInstance())
return true;
TItemPos tPos;
tPos.window_type = INVENTORY;
tPos.cell = 0;
TPacketSash sPacket;
sPacket.header = HEADER_CG_SASH;
sPacket.subheader = SASH_SUBHEADER_CG_REMOVE;
sPacket.dwPrice = 0;
sPacket.bPos = bPos;
sPacket.tPos = tPos;
sPacket.dwItemVnum = 0;
sPacket.dwMinAbs = 0;
sPacket.dwMaxAbs = 0;
if (!Send(sizeof(sPacket), &sPacket))
return false;
return SendSequence();
}
bool CPythonNetworkStream::SendSashRefinePacket()
{
if (!__CanActMainInstance())
return true;
TItemPos tPos;
tPos.window_type = INVENTORY;
tPos.cell = 0;
TPacketSash sPacket;
sPacket.header = HEADER_CG_SASH;
sPacket.subheader = SASH_SUBHEADER_CG_REFINE;
sPacket.dwPrice = 0;
sPacket.bPos = 0;
sPacket.tPos = tPos;
sPacket.dwItemVnum = 0;
sPacket.dwMinAbs = 0;
sPacket.dwMaxAbs = 0;
if (!Send(sizeof(sPacket), &sPacket))
return false;
return SendSequence();
}
+28
View File
@@ -422,6 +422,34 @@ void CPythonPlayer::__UpdateBattleStatus()
m_playerStatus.SetPoint(POINT_MAX_MAGIC_WEP, m_dwWeaponMaxMagicPower+m_dwWeaponAddPower);
m_playerStatus.SetPoint(POINT_MIN_ATK, __GetTotalAtk(m_dwWeaponMinPower, m_dwWeaponAddPower));
m_playerStatus.SetPoint(POINT_MAX_ATK, __GetTotalAtk(m_dwWeaponMaxPower, m_dwWeaponAddPower));
// PORT: AVA2 PythonPlayer.cpp (ENABLE_SASH_SYSTEM): an equipped sash that absorbed a weapon adds
// (value3|4 + value5) and magic (value1|2 + value5) x absorption% to the shown attack, as the
// server adds it in Item_GetDamage / SetPolyVarForAttack.
const TItemData * pSashData = GetItemData(TItemPos(INVENTORY, c_Costume_Slot_Sash));
if (pSashData)
{
long lAbsorbedVnum = pSashData->alSockets[SASH_ABSORBED_SOCKET];
CItemData * pAbsorbed;
if (lAbsorbedVnum != 0 && CItemManager::Instance().GetItemDataPointer(lAbsorbedVnum, &pAbsorbed))
{
if (pAbsorbed->GetType() == CItemData::ITEM_TYPE_WEAPON)
{
double dAbs = static_cast<double>(pSashData->alSockets[SASH_ABSORPTION_SOCKET]) / 100.0;
long lMinWep = static_cast<long>((pAbsorbed->GetValue(3) + pAbsorbed->GetValue(5)) * dAbs + 0.5);
long lMaxWep = static_cast<long>((pAbsorbed->GetValue(4) + pAbsorbed->GetValue(5)) * dAbs + 0.5);
long lMinMag = static_cast<long>((pAbsorbed->GetValue(1) + pAbsorbed->GetValue(5)) * dAbs + 0.5);
long lMaxMag = static_cast<long>((pAbsorbed->GetValue(2) + pAbsorbed->GetValue(5)) * dAbs + 0.5);
m_playerStatus.SetPoint(POINT_MIN_WEP, m_playerStatus.GetPoint(POINT_MIN_WEP) + lMinWep);
m_playerStatus.SetPoint(POINT_MAX_WEP, m_playerStatus.GetPoint(POINT_MAX_WEP) + lMaxWep);
m_playerStatus.SetPoint(POINT_MIN_MAGIC_WEP, m_playerStatus.GetPoint(POINT_MIN_MAGIC_WEP) + lMinMag);
m_playerStatus.SetPoint(POINT_MAX_MAGIC_WEP, m_playerStatus.GetPoint(POINT_MAX_MAGIC_WEP) + lMaxMag);
m_playerStatus.SetPoint(POINT_MIN_ATK, m_playerStatus.GetPoint(POINT_MIN_ATK) + lMinWep);
m_playerStatus.SetPoint(POINT_MAX_ATK, m_playerStatus.GetPoint(POINT_MAX_ATK) + lMaxWep);
}
}
}
}
void CPythonPlayer::SetStatus(DWORD dwType, long lValue)
+189
View File
@@ -0,0 +1,189 @@
#include "StdAfx.h"
// PORT: AVA2 PythonSash.cpp (ENABLE_SASH_SYSTEM) verbatim; the sash module is driven by mt_sash.
#include "PythonSash.h"
#include "PythonNetworkStream.h"
CPythonSash::CPythonSash()
{
Clear();
}
CPythonSash::~CPythonSash()
{
Clear();
}
void CPythonSash::Clear()
{
dwPrice = 0;
ZeroMemory(&m_vSashResult, sizeof(m_vSashResult));
m_vSashMaterials.clear();
m_vSashMaterials.resize(SASH_WINDOW_MAX_MATERIALS);
for (BYTE bPos = 0; bPos < m_vSashMaterials.size(); ++bPos)
{
TSashMaterial tMaterial;
tMaterial.bHere = 0;
tMaterial.wCell = 0;
m_vSashMaterials[bPos] = tMaterial;
}
}
void CPythonSash::AddMaterial(DWORD dwRefPrice, BYTE bPos, TItemPos tPos)
{
if (bPos >= SASH_WINDOW_MAX_MATERIALS)
return;
if (bPos == 0)
dwPrice = dwRefPrice;
TSashMaterial tMaterial;
tMaterial.bHere = 1;
tMaterial.wCell = tPos.cell;
m_vSashMaterials[bPos] = tMaterial;
}
void CPythonSash::AddResult(DWORD dwItemVnum, DWORD dwMinAbs, DWORD dwMaxAbs)
{
TSashResult tResult;
tResult.dwItemVnum = dwItemVnum;
tResult.dwMinAbs = dwMinAbs;
tResult.dwMaxAbs = dwMaxAbs;
m_vSashResult = tResult;
}
void CPythonSash::RemoveMaterial(DWORD dwRefPrice, BYTE bPos)
{
if (bPos >= SASH_WINDOW_MAX_MATERIALS)
return;
if (bPos == 1)
dwPrice = dwRefPrice;
TSashMaterial tMaterial;
tMaterial.bHere = 0;
tMaterial.wCell = 0;
m_vSashMaterials[bPos] = tMaterial;
}
bool CPythonSash::GetAttachedItem(BYTE bPos, BYTE & bHere, WORD & wCell)
{
if (bPos >= SASH_WINDOW_MAX_MATERIALS)
return false;
bHere = m_vSashMaterials[bPos].bHere;
wCell = m_vSashMaterials[bPos].wCell;
return true;
}
void CPythonSash::GetResultItem(DWORD & dwItemVnum, DWORD & dwMinAbs, DWORD & dwMaxAbs)
{
dwItemVnum = m_vSashResult.dwItemVnum;
dwMinAbs = m_vSashResult.dwMinAbs;
dwMaxAbs = m_vSashResult.dwMaxAbs;
}
PyObject * SendSashCloseRequest(PyObject * poSelf, PyObject * poArgs)
{
CPythonNetworkStream & rkNetStream = CPythonNetworkStream::Instance();
rkNetStream.SendSashClosePacket();
return Py_BuildNone();
}
PyObject * SendSashAdd(PyObject * poSelf, PyObject * poArgs)
{
BYTE bPos;
TItemPos tPos;
if (!PyTuple_GetInteger(poArgs, 0, &tPos.window_type))
return Py_BuildException();
else if (!PyTuple_GetInteger(poArgs, 1, &tPos.cell))
return Py_BuildException();
else if (!PyTuple_GetInteger(poArgs, 2, &bPos))
return Py_BuildException();
CPythonNetworkStream & rkNetStream = CPythonNetworkStream::Instance();
rkNetStream.SendSashAddPacket(tPos, bPos);
return Py_BuildNone();
}
PyObject * SendSashRemove(PyObject * poSelf, PyObject * poArgs)
{
BYTE bPos;
if (!PyTuple_GetInteger(poArgs, 0, &bPos))
return Py_BuildException();
CPythonNetworkStream & rkNetStream = CPythonNetworkStream::Instance();
rkNetStream.SendSashRemovePacket(bPos);
return Py_BuildNone();
}
PyObject * GetSashPrice(PyObject * poSelf, PyObject * poArgs)
{
return Py_BuildValue("i", CPythonSash::Instance().GetPrice());
}
PyObject * GetSashAttachedItem(PyObject * poSelf, PyObject * poArgs)
{
BYTE bPos;
if (!PyTuple_GetInteger(poArgs, 0, &bPos))
return Py_BuildException();
BYTE bHere;
WORD wCell;
bool bGet = CPythonSash::Instance().GetAttachedItem(bPos, bHere, wCell);
if (!bGet)
{
bHere = 0;
wCell = 0;
}
return Py_BuildValue("ii", bHere, wCell);
}
PyObject * GetSashResultItem(PyObject * poSelf, PyObject * poArgs)
{
DWORD dwItemVnum, dwMinAbs, dwMaxAbs;
CPythonSash::Instance().GetResultItem(dwItemVnum, dwMinAbs, dwMaxAbs);
return Py_BuildValue("iii", dwItemVnum, dwMinAbs, dwMaxAbs);
}
PyObject * SendSashRefineRequest(PyObject * poSelf, PyObject * poArgs)
{
BYTE bHere;
WORD wCell;
bool bGet = CPythonSash::Instance().GetAttachedItem(1, bHere, wCell);
if (bGet)
{
if (bHere)
{
CPythonNetworkStream & rkNetStream = CPythonNetworkStream::Instance();
rkNetStream.SendSashRefinePacket();
}
}
return Py_BuildNone();
}
void initSash()
{
static PyMethodDef functions[] = {
{"SendCloseRequest", SendSashCloseRequest, METH_VARARGS},
{"Add", SendSashAdd, METH_VARARGS},
{"Remove", SendSashRemove, METH_VARARGS},
{"GetPrice", GetSashPrice, METH_VARARGS},
{"GetAttachedItem", GetSashAttachedItem, METH_VARARGS},
{"GetResultItem", GetSashResultItem, METH_VARARGS},
{"SendRefineRequest", SendSashRefineRequest, METH_VARARGS},
{NULL, NULL, NULL},
};
PyObject* pModule = Py_InitModule("sash", functions);
PyModule_AddIntConstant(pModule, "ABSORPTION_SOCKET", SASH_ABSORPTION_SOCKET);
PyModule_AddIntConstant(pModule, "ABSORBED_SOCKET", SASH_ABSORBED_SOCKET);
PyModule_AddIntConstant(pModule, "WINDOW_MAX_MATERIALS", SASH_WINDOW_MAX_MATERIALS);
PyModule_AddIntConstant(pModule, "CLEAN_ATTR_VALUE_FIELD", 0);
PyModule_AddIntConstant(pModule, "LIMIT_RANGE", 1000);
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
// PORT: AVA2 sash window state (ENABLE_SASH_SYSTEM), fed by HEADER_GC_SASH.
#include "Packet.h"
class CPythonSash : public CSingleton<CPythonSash>
{
public:
DWORD dwPrice;
typedef std::vector<TSashMaterial> TSashMaterials;
public:
CPythonSash();
virtual ~CPythonSash();
void Clear();
void AddMaterial(DWORD dwRefPrice, BYTE bPos, TItemPos tPos);
void AddResult(DWORD dwItemVnum, DWORD dwMinAbs, DWORD dwMaxAbs);
void RemoveMaterial(DWORD dwRefPrice, BYTE bPos);
DWORD GetPrice() {return dwPrice;}
bool GetAttachedItem(BYTE bPos, BYTE & bHere, WORD & wCell);
void GetResultItem(DWORD & dwItemVnum, DWORD & dwMinAbs, DWORD & dwMaxAbs);
protected:
TSashResult m_vSashResult;
TSashMaterials m_vSashMaterials;
};
+7 -1
View File
@@ -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).
int GetEffectLevel() { return m_iPortEffectLevel; }
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 IsNoSoundCard();
bool IsAutoTiling();
@@ -163,4 +167,6 @@ class CPythonSystem : public CSingleton<CPythonSystem>
PyObject * m_poInterfaceHandler;
TWindowStatus m_WindowStatus[WINDOW_MAX_NUM];
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());
}
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)
{
return Py_BuildValue("i", CPythonSystem::Instance().IsAlwaysShowName());
@@ -440,6 +463,10 @@ void initsystem()
{ "IsShowSalesText", systemIsShowSalesText, METH_VARARGS },
{ "SetEffectLevel", systemSetEffectLevel, METH_VARARGS }, // PORT: AVA2
{ "GetEffectLevel", systemGetEffectLevel, METH_VARARGS },
{ "SetShopNamesRange", systemSetShopNamesRange, METH_VARARGS },
{ "GetShopNamesRange", systemGetShopNamesRange, METH_VARARGS },
{ "SetShopNameStatus", systemSetShopNameStatus, METH_VARARGS },
{ "GetShopNameStatus", systemGetShopNameStatus, METH_VARARGS },
{ "GetShadowLevel", systemGetShadowLevel, METH_VARARGS },
{ "SetShadowLevel", systemSetShadowLevel, METH_VARARGS },
@@ -6,6 +6,7 @@
#include "resource.h"
#include "PythonTextTail.h"
#include "PythonCharacterManager.h"
#include "PythonSystem.h"
#include "PythonGuild.h"
#include "Locale.h"
#include "MarkManager.h"
@@ -463,6 +464,11 @@ void CPythonTextTail::ShowCharacterTextTail(DWORD VirtualID)
if (pInstance->IsGuildWall())
return;
if (!pInstance->PORT_IsDisplayVisible())
return;
if (pInstance->IsNPC() && (pInstance->GetRace() < 30000 || pInstance->GetRace() > 30020) &&
!CPythonSystem::Instance().GetShopNameStatus())
return;
if (pInstance->CanPickInstance())
m_CharacterTextTailList.push_back(pTextTail);
+1
View File
@@ -68,6 +68,7 @@ void initskill();
void initBackground();
void initMessenger();
void initsafebox();
void initSash(); // PORT: AVA2 sash
void initguild();
void initServerStateChecker();
void initRenderTarget(); // PORT: AVA2 CPythonRenderTargetModule
+66
View File
@@ -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");
}
+60
View File
@@ -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()
+52
View File
@@ -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()
+79
View File
@@ -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()
+103
View File
@@ -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");
}
+4 -4
View File
@@ -43,7 +43,7 @@ inline constexpr int ID_MAX_NUM = 30; // Packet.h -> login[31]
inline constexpr int PASS_MAX_NUM = 16; // -> passwd[17]
inline constexpr int PRIVATE_CODE_LENGTH = 8;
inline constexpr int POINT_MAX_NUM = 255; // common/length.h:58
inline constexpr int CHR_EQUIPPART_NUM = 4; // ARMOR/WEAPON/HEAD/HAIR
inline constexpr int CHR_EQUIPPART_NUM = 5; // ARMOR/WEAPON/HEAD/HAIR + AVA2 SASH (metin2-server P4)
inline constexpr int LOGIN_STATUS_MAX_LEN = 8; // -> szStatus[9]
inline constexpr int KEY_AGREEMENT_MAX_DATA_LEN = 256;
inline constexpr int ITEM_SOCKET_MAX_NUM = 3; // common/item_length.h
@@ -645,7 +645,7 @@ struct GCCharacterAdd2 { // packet_add_char2 — name + parts + guild
uint32_t mount_vnum;
};
static_assert(sizeof(GCCharacterAdd2) ==
1 + 4 + (CHARACTER_NAME_MAX_LEN + 1) + 4 + 12 + 1 + 2 + 8 + 1 + 1 + 1 + 8 + 1 + 4 + 2 + 1 + 4); // 120
1 + 4 + (CHARACTER_NAME_MAX_LEN + 1) + 4 + 12 + 1 + 2 + 2 * CHR_EQUIPPART_NUM + 1 + 1 + 1 + 8 + 1 + 4 + 2 + 1 + 4); // 122
struct GCCharacterDel { uint8_t header; uint32_t vid; };
static_assert(sizeof(GCCharacterDel) == 5);
@@ -873,7 +873,7 @@ struct GCCharacterUpdate { // packet_update_char (header 19)
uint8_t pk_mode;
uint32_t mount_vnum;
};
static_assert(sizeof(GCCharacterUpdate) == 1 + 4 + 8 + 1 + 1 + 1 + 8 + 4 + 2 + 1 + 4); // 35
static_assert(sizeof(GCCharacterUpdate) == 1 + 4 + 2 * CHR_EQUIPPART_NUM + 1 + 1 + 1 + 8 + 4 + 2 + 1 + 4); // 37
struct GCStun { uint8_t header; uint32_t vid; }; // header 13
struct GCDead { uint8_t header; uint32_t vid; }; // header 14
@@ -1036,7 +1036,7 @@ struct GCCharAddInfo {
uint8_t pk_mode;
uint32_t mount_vnum;
};
static_assert(sizeof(GCCharAddInfo) == 1 + 4 + (CHARACTER_NAME_MAX_LEN + 1) + 8 + 1 + 4 + 4 + 2 + 1 + 4); // 54
static_assert(sizeof(GCCharAddInfo) == 1 + 4 + (CHARACTER_NAME_MAX_LEN + 1) + 2 * CHR_EQUIPPART_NUM + 1 + 4 + 4 + 2 + 1 + 4); // 56
// --- GC skills / quickslots ---
struct PlayerSkill3 { // TPlayerSkill (common/tables.h) — time_t is 32-bit on the -m32 server
+80
View File
@@ -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;}
}
+22 -1
View File
@@ -45,7 +45,7 @@ INFO=['MTBP1 season 202610 1790805600 1793487600 1790900000 6',
class BattlePassTests(unittest.TestCase):
def setUp(self):
self.chat=[];self.sent=[];self.clock=100.
names=('app','chat','game','grp','item','net','nonplayer','ui','mt_biolog')
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)
@@ -54,6 +54,9 @@ class BattlePassTests(unittest.TestCase):
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
@@ -146,6 +149,24 @@ class BattlePassTests(unittest.TestCase):
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)
+7 -3
View File
@@ -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')}
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.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.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))
@@ -58,9 +60,9 @@ class DisplayTests(unittest.TestCase):
def test_defaults_and_config(self):
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'):
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')
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)
w.levelButtons[4].event();self.assertEqual(self.state['effect'],4)
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))
self.assertNotIn('【',w.nameButton.text)
game.GameWindow.Close(gw);self.assertIsNone(ns['g_window']);self.assertIsNone(ns['g_entry'])
+79
View File
@@ -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()
+1 -1
View File
@@ -41,7 +41,7 @@ class SwitchbotTests(unittest.TestCase):
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
it=mods['item'];it.ITEM_TYPE_WEAPON=1;it.ITEM_TYPE_ARMOR=2;it.ITEM_TYPE_USE=3;self.selected=None
it=mods['item'];it.ITEM_TYPE_WEAPON=1;it.ITEM_TYPE_ARMOR=2;it.ITEM_TYPE_USE=3;it.ARMOR_PENDANT=7;self.selected=None
it.SelectItem=lambda v:setattr(self,'selected',v);it.GetItemType=lambda:protos[self.selected][0];it.GetItemSubType=lambda:protos[self.selected][1]
it.GetItemName=lambda:'N%d'%self.selected
pl=mods['player'];pl.INVENTORY=1;pl.INVENTORY_PAGE_SIZE=45;pl.INVENTORY_PAGE_COUNT=5
+5148
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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" }
+9
View File
@@ -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"] }
@@ -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
));
}
}
+26
View File
@@ -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
View File
@@ -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();
}
}

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