layout: restructure into src/ tests/ android/ scripts/ tools/

- extension/src/{port,platform,codepage} -> src/; native_render -> src/host
  (+ dxt, shaders/); libgr2 -> src/gr2; extension/third_party -> third_party
- extension/tests -> tests/port, libgr2/tests -> tests/gr2
- android-native -> android (build.sh, push-client.sh moved in)
- script -> scripts; tools/40250 -> tools/server; oracle -> tools/granny-oracle;
  perf tools -> tools/perf
- all build trees under build/ (native, release, android, port-gate)
- xrender:: CMake aliases -> mt::; port-map ledger impl paths rewritten

No code changes. ctest 15/15, port_gate macos+android PASS, port_map check 0 errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-29 19:08:19 +09:00
co-authored by Claude Opus 5.5
parent 70710477cf
commit a46093104c
2817 changed files with 13728 additions and 13744 deletions
+96
View File
@@ -0,0 +1,96 @@
# 40250 FreeBSD + MySQL live E2E
这组脚本把文档里最后一个不能在 macOS 本机完成的前置变成可执行入口:
1. 在 FreeBSD jail/VM 准备 40250 server tree、MySQL 和 32-bit toolchain。
2. 导入 `metin2_mysql_dump/{account,common,log,player}.sql`。
3. 编译(或使用已编译的)`game` / `db`,安装 server runtime links。
4. 启动 db、auth、channel 1–4 和 game99。
5. 用 `net_classic_e2e_host` 验证 DH2/CTR、登录、选角和 `PHASE_GAME`。
当前 Mac 环境没有 FreeBSD 或 MySQL server,因此这里只能完成脚本、构建配置和
离线协议测试;不能把真实服结果冒充为已执行。脚本均使用环境变量,避免把本机
路径或密码写死到工程中。
## 复制/准备目录
```sh
export MT40250_ROOT=/usr/metin2
export MT40250_DUMP_ROOT=/usr/metin2_mysql_dump
```
`MT40250_ROOT` 必须是包含 `src/server` 和 `server/` 的 40250 server tree。
官方 Makefile 默认把二进制放到 `/usr/metin2/server/share/bin`,所以推荐保持
这个路径;若使用其他路径,必须同时确保 Makefile 的 `GAMEDIR`/`DBDIR` 指向
对应 runtime 目录。
## FreeBSD 安装与编译
依赖由管理员按镜像版本安装,典型组合如下(具体包名以目标 FreeBSD 版本为准):
```sh
pkg install gmake clang-devel mysql80-server mysql80-client
sh tools/server/build_server.sh
```
源码 Makefile 是 40250 原版,使用 `clang++-devel`、`-m32`、静态 MySQL client、
Crypto++ 8.4.0 和 FreeBSD 系统库;`build_server.sh` 会执行原始的 `gmake -C
src/server all`,失败时应保留完整编译输出修复 jail 的 32-bit/库依赖。
## 初始化数据库
先启动 MySQL,再执行:
```sh
export MT40250_MYSQL_ROOT_PASSWORD='你的 root 密码' # 不设置则每次交互式 -p
export MT40250_DB_PASSWORD=password # 必须与 CONFIG 一致,默认 password
sh tools/server/setup_mysql.sh
```
脚本创建 `account/common/log/player`,并同时建立 `metin2@localhost` 与
`metin2@127.0.0.1`;这是因为 CONFIG 使用 TCP `127.0.0.1`,而旧 Readme 只写了
`localhost`。导入后会执行最小连接检查。
## 启停 40250 服务
```sh
sh tools/server/prepare_runtime.sh
sh tools/server/start_server.sh
sh tools/server/stop_server.sh
```
`start_server.sh` 使用仓库自带 CONFIG:auth `11000`、channel1/first `13000`,
并启动 channel1–4 与 game99。每个进程的 stdout/stderr 写入其 runtime 目录的
`console.log`,PID 只记录在 `$MT40250_ROOT/.mt40250-pids`;停止脚本只终止这份
PID 文件中的进程。可用 `MT40250_CHANNELS='1'` 只启动 channel1,或设置
`MT40250_START_GAME99=0` 跳过 game99。
## 编译/运行 classic 探针
顶层 CMake 提供独立的 host-only 协议探针:
```sh
cmake -S . -B build-classic \
-DMT_BUILD_NET_TOOLS=ON \
-DMT_BUILD_PORT=OFF \
-DCMAKE_BUILD_TYPE=Release
cmake --build build-classic --target net_classic_e2e_host -j2
build-classic/net_classic_e2e_host 127.0.0.1 13000 test_account test_password 0 30
```
探针成功的判据是输出 `PASS PHASE_GAME`,并且打印主角 VID、实体数、背包槽位数
和等级。它不会自动创建角色、交易、购买或移动仓库物品;这些操作属于有副作用的
fixture 测试,应在测试账号/NPC/第二账号准备后通过原生客户端逐项操作:好友 add/remove、exchange start/item/gold/accept/cancel、
NPC safebox checkin/checkout/move、mall checkout、Cube open/add/delete/make。
## 40250 配置核对
- `auth/CONFIG`: `PORT 11000`。
- `channel1/first/CONFIG`: `PORT 13000`,`MAP_ALLOW` 包含起始地图。
- 所有 CONFIG 的 DB 端口是 `15000`,由 `server/db/conf.txt` 控制。
- `PLAYER_SQL` 指向 `player`,auth 的 `PLAYER_SQL` 指向 `account`,两者与原服
源码约定一致。
- `service.h` 默认定义 `_IMPROVED_PACKET_ENCRYPTION_`;classic 客户端必须走
DH2 + CTR,不能用“明文配置项”绕过。若专门做明文诊断,必须复制源码、重新
编译 game,并把它视为与 shipped server 不同的测试构建。
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
if [ "$(uname -s)" != "FreeBSD" ]; then
echo "build_server.sh: 40250 server Makefile is FreeBSD-only" >&2
exit 2
fi
server_root=${MT40250_ROOT:-/usr/metin2}
if [ ! -d "$server_root/src/server" ]; then
echo "build_server.sh: missing $server_root/src/server" >&2
exit 2
fi
if ! command -v gmake >/dev/null 2>&1; then
echo "build_server.sh: install gmake first" >&2
exit 2
fi
cd "$server_root"
gmake -C src/server all
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
#
# PROVIDE: mt_register
# REQUIRE: LOGIN mysql
# KEYWORD: shutdown
#
# Account registration service for the 40250 server (tools/server/register_server.py).
# Install: /usr/local/etc/rc.d/mt_register, then `sysrc mt_register_enable=YES`.
. /etc/rc.subr
name=mt_register
rcvar=mt_register_enable
load_rc_config $name
: ${mt_register_enable:=NO}
: ${mt_register_dir:=/usr/metin2/server/register}
pidfile=/var/run/${name}.pid
procname=/usr/local/bin/python3.9
command=/usr/sbin/daemon
command_args="-f -p ${pidfile} -o ${mt_register_dir}/register.log ${procname} ${mt_register_dir}/register_server.py"
run_rc_command "$1"
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
server_root=${MT40250_ROOT:-/usr/metin2}
runtime="$server_root/server"
if [ "$(uname -s)" != "FreeBSD" ]; then
echo "prepare_runtime.sh: runtime binaries are FreeBSD-only" >&2
exit 2
fi
if [ ! -x "$runtime/install.sh" ]; then
echo "prepare_runtime.sh: missing $runtime/install.sh" >&2
exit 2
fi
cd "$runtime"
sh ./install.sh
echo "40250 runtime links and log/core directories ready under $runtime"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/local/bin/python3.9
"""Account registration service for the 40250 server (PORT: 40250 has no in-client registration).
POST /register with a form body `login=..&password=..&social_id=..` inserts a row into
account.account the same way the existing accounts are stored (password = MySQL PASSWORD()).
The reply is one plain-text line the client shows directly: `OK` or `ERR <CODE>`, where CODE is
BAD_LOGIN, BAD_PASSWORD, BAD_SOCIAL_ID, EXISTS, RATE_LIMIT, BAD_REQUEST or SERVER.
Deployed to /usr/metin2/server/register/ and started by /usr/local/etc/rc.d/mt_register.
"""
import argparse
import re
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
LOGIN_RE = re.compile(r"^[A-Za-z0-9]{4,16}$") # LoginWindow ID_EditLine input_limit 16
PASSWORD_RE = re.compile(r"^[\x21-\x7e]{4,16}$") # printable ASCII, Password_EditLine input_limit 16
SOCIAL_ID_RE = re.compile(r"^[0-9]{7}$") # the delete code introselect asks for
RATE_WINDOW = 3600
RATE_MAX = 5 # successful registrations per IP per hour
MAX_BODY = 1024
_rate_lock = threading.Lock()
_rate = {} # ip -> [timestamps of successful registrations]
def log(msg):
sys.stdout.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n")
sys.stdout.flush()
def rate_limited(ip):
now = time.time()
with _rate_lock:
hits = [t for t in _rate.get(ip, []) if now - t < RATE_WINDOW]
_rate[ip] = hits
return len(hits) >= RATE_MAX
def rate_record(ip):
with _rate_lock:
_rate.setdefault(ip, []).append(time.time())
def create_account(login, password, social_id):
# Every value is validated above; the password additionally travels as hex so no quoting
# can leak into the statement.
sql = ("INSERT INTO account.account (login, password, social_id, status, create_time, last_play) "
"VALUES ('%s', PASSWORD(UNHEX('%s')), '%s', 'OK', NOW(), NOW())"
% (login, password.encode("ascii").hex(), social_id))
r = subprocess.run(["/usr/local/bin/mysql", "-uroot", "-N", "-e", sql], capture_output=True, text=True, timeout=10)
if r.returncode == 0:
return "OK"
if "Duplicate entry" in r.stderr or "ERROR 1062" in r.stderr:
return "ERR EXISTS"
log("mysql error: " + r.stderr.strip())
return "ERR SERVER"
class Handler(BaseHTTPRequestHandler):
server_version = "mt-register/1"
def log_message(self, fmt, *args):
pass
def reply(self, code, text):
body = (text + "\n").encode("ascii")
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/ping":
self.reply(200, "OK")
else:
self.reply(404, "ERR BAD_REQUEST")
def do_POST(self):
ip = self.client_address[0]
if self.path != "/register":
return self.reply(404, "ERR BAD_REQUEST")
try:
n = int(self.headers.get("Content-Length", "0"))
except ValueError:
n = -1
if n < 0 or n > MAX_BODY:
return self.reply(400, "ERR BAD_REQUEST")
try:
form = parse_qs(self.rfile.read(n).decode("ascii"), keep_blank_values=True)
except (UnicodeDecodeError, ValueError):
return self.reply(400, "ERR BAD_REQUEST")
login = form.get("login", [""])[0]
password = form.get("password", [""])[0]
social_id = form.get("social_id", [""])[0]
if not LOGIN_RE.match(login):
result = "ERR BAD_LOGIN"
elif not PASSWORD_RE.match(password):
result = "ERR BAD_PASSWORD"
elif not SOCIAL_ID_RE.match(social_id):
result = "ERR BAD_SOCIAL_ID"
elif rate_limited(ip):
result = "ERR RATE_LIMIT"
else:
result = create_account(login.lower(), password, social_id)
if result == "OK":
rate_record(ip)
log("%s register %s -> %s" % (ip, login, result))
self.reply(200, result)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--bind", default="0.0.0.0")
ap.add_argument("--port", type=int, default=11080)
args = ap.parse_args()
httpd = ThreadingHTTPServer((args.bind, args.port), Handler)
log("listening on %s:%d" % (args.bind, args.port))
httpd.serve_forever()
if __name__ == "__main__":
main()
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
cli=${MT_CLASSIC_E2E:-${MTGODOT_CLASSIC_E2E:-}}
if [ -z "$cli" ]; then
build_root=${MT_CLASSIC_BUILD:-${MTGODOT_CLASSIC_BUILD:-build-classic}}
cli="$build_root/net_classic_e2e_host"
fi
if [ ! -x "$cli" ]; then
echo "run_classic_e2e.sh: missing $cli; build with -DMT_BUILD_NET_TOOLS=ON" >&2
exit 2
fi
host=${MT40250_GAME_HOST:-127.0.0.1}
port=${MT40250_GAME_PORT:-13000}
: "${MT40250_ACCOUNT_ID:?set MT40250_ACCOUNT_ID}"
: "${MT40250_ACCOUNT_PASSWORD:?set MT40250_ACCOUNT_PASSWORD}"
slot=${MT40250_CHARACTER_SLOT:-0}
timeout=${MT40250_E2E_TIMEOUT_SECONDS:-30}
exec "$cli" "$host" "$port" "$MT40250_ACCOUNT_ID" "$MT40250_ACCOUNT_PASSWORD" "$slot" "$timeout"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
set -eu
dump_root=${MT40250_DUMP_ROOT:-/usr/metin2_mysql_dump}
mysql_bin=${MT40250_MYSQL_BIN:-}
if [ -z "$mysql_bin" ]; then
mysql_bin=$(command -v mysql || true)
fi
if [ -z "$mysql_bin" ] || [ ! -x "$mysql_bin" ]; then
echo "setup_mysql.sh: mysql client not found" >&2
exit 2
fi
for db in account common log player; do
if [ ! -f "$dump_root/$db.sql" ]; then
echo "setup_mysql.sh: missing $dump_root/$db.sql" >&2
exit 2
fi
done
db_password=${MT40250_DB_PASSWORD:-password}
case "$db_password" in
*"'"*) echo "setup_mysql.sh: DB password may not contain a single quote" >&2; exit 2 ;;
esac
mysql_root() {
if [ -n "${MT40250_MYSQL_ROOT_PASSWORD:-}" ]; then
MYSQL_PWD="$MT40250_MYSQL_ROOT_PASSWORD" "$mysql_bin" -u root "$@"
else
"$mysql_bin" -u root -p "$@"
fi
}
mysql_root -e "CREATE DATABASE IF NOT EXISTS account; CREATE DATABASE IF NOT EXISTS common; CREATE DATABASE IF NOT EXISTS log; CREATE DATABASE IF NOT EXISTS player; CREATE USER IF NOT EXISTS 'metin2'@'localhost' IDENTIFIED BY '$db_password'; CREATE USER IF NOT EXISTS 'metin2'@'127.0.0.1' IDENTIFIED BY '$db_password'; ALTER USER 'metin2'@'localhost' IDENTIFIED BY '$db_password'; ALTER USER 'metin2'@'127.0.0.1' IDENTIFIED BY '$db_password'; GRANT ALL PRIVILEGES ON *.* TO 'metin2'@'localhost' WITH GRANT OPTION; GRANT ALL PRIVILEGES ON *.* TO 'metin2'@'127.0.0.1' WITH GRANT OPTION; FLUSH PRIVILEGES;"
for db in account common log player; do
echo "importing $db.sql"
if [ -n "${MT40250_MYSQL_ROOT_PASSWORD:-}" ]; then
MYSQL_PWD="$MT40250_MYSQL_ROOT_PASSWORD" "$mysql_bin" -u root "$db" < "$dump_root/$db.sql"
else
"$mysql_bin" -u root -p "$db" < "$dump_root/$db.sql"
fi
done
mysql_root -e "SELECT 1 AS metin2_database_ready;"
echo "40250 databases ready"
+53
View File
@@ -0,0 +1,53 @@
#!/bin/sh
set -eu
server_root=${MT40250_ROOT:-/usr/metin2}
runtime="$server_root/server"
pid_file="$server_root/.mt40250-pids"
if [ "$(uname -s)" != "FreeBSD" ]; then
echo "start_server.sh: 40250 runtime is FreeBSD-only" >&2
exit 2
fi
if [ ! -x "$runtime/db/db" ] || [ ! -x "$runtime/auth/auth" ]; then
echo "start_server.sh: run prepare_runtime.sh first" >&2
exit 2
fi
if [ -f "$pid_file" ]; then
echo "start_server.sh: $pid_file exists; stop the existing server first" >&2
exit 2
fi
launch() {
name=$1
dir=$2
bin=$3
if [ ! -x "$dir/$bin" ]; then
echo "start_server.sh: missing $dir/$bin" >&2
exit 2
fi
(
cd "$dir"
exec "./$bin"
) >> "$dir/console.log" 2>&1 &
pid=$!
echo "$pid $name" >> "$pid_file"
echo "started $name pid=$pid"
}
: > "$pid_file"
launch db "$runtime/db" db
sleep "${MT40250_DB_WAIT_SECONDS:-2}"
launch auth "$runtime/auth" auth
channels=${MT40250_CHANNELS:-"1 2 3 4"}
for channel in $channels; do
for node in first game1 game2; do
launch "channel${channel}/${node}" "$runtime/channel${channel}/${node}" game
done
done
if [ "${MT40250_START_GAME99:-1}" = "1" ]; then
launch game99 "$runtime/game99" game
fi
echo "40250 server started; logs are under $runtime/*/console.log"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
server_root=${MT40250_ROOT:-/usr/metin2}
pid_file="$server_root/.mt40250-pids"
if [ ! -f "$pid_file" ]; then
echo "stop_server.sh: no $pid_file"
exit 0
fi
while read -r pid name; do
case "$pid" in
''|*[!0-9]*) continue ;;
esac
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
echo "stopped $name pid=$pid"
fi
done < "$pid_file"
rm -f "$pid_file"