| 部分 | 选型 | 位置 |
|---|---|---|
| 客户端 | 原版 Blizzard WoW.exe(打过 vanilla-tweaks),跑在 WoWSilicon 的 Wine 上 |
client/(enUS)、client-zhCN/(简中) |
| 服务端 | VMaNGOS(via vmangos-deploy,Docker) | 线上 $SERVER_HOST:/opt/vmangos(在跑的那份);本地 vmangos-deploy(arm64,停着当后备) |
| 客户端资源 | 原版 enUS 1.12.1 的 12 个 MPQ(sha256 全部校验通过) | vmangos-deploy/storage/mangosd/client-data/Data |
AzerothCore 只支持 WotLK 3.3.5a,不支持 1.12 协议,因此 Vanilla 路线改用 VMaNGOS。
早期用过 Wowee(原生 macOS/Vulkan 的重实现客户端),2026-09-03 弃用——它的
classicprofile 有训练师列表字段错位这类编译进 native 代码、Lua 层修不了的缺陷, 而原版二进制就是行为的参考实现,这些问题根本不存在。当时的排查记录、本地修复用的 两个 addon 和相关脚本打包在wowee-removed-20260903.tar.gz。
- 认证服务器地址:
server.conf里的SERVER_HOST(端口 3724) - 领域:
VMaNGOS@$SERVER_HOST:8085 - 账号:自己建,见下面「建号」(
bin/mangos-console.py "account create <名字> <密码>")
⚠️ GM 账号直接暴露在公网上,口令要够强。 realmd 有WrongPass.MaxAttempts = 10/ThrottleWindowDurationSec = 60的限流, 但扛不住有针对性的爆破。改密码(走 mangosd 控制台,密码要打两遍):ssh "$SERVER_SSH_USER@$SERVER_HOST" -t 'docker attach vmangos-mangosd-1' # 提示符出来后输: # account set password <账号> <新密码> <新密码> # 然后 Ctrl-P Ctrl-Q 脱离——不要 Ctrl-C,那会把 mangosd 一起停掉改完记得
SERVER_HOST=$SERVER_HOST bin/auth-check.py <账号> <新密码>验一下。
服务端搬到了一台 VPS(Ubuntu 24.04 / x86_64 / 4 核 / 3.8G 内存),
目录 /opt/vmangos,和本地 vmangos-deploy 同一套 compose,只有
VMANGOS_REALMLIST_ADDRESS 从 127.0.0.1 改成了那台机器的公网地址
(这个值由 database 容器写进 realmd.realmlist 表,客户端选完领域后
按它去连世界服务器——填错的话能登录但进不去游戏)。
搬过去的东西:
| 内容 | 大小 | 方式 |
|---|---|---|
storage/mangosd/extracted-data(maps/vmaps/mmaps/dbc) |
2.7G / 10716 个文件 | rsync |
config/mangosd.conf、config/realmd.conf |
— | scp,逐字节相同 |
storage/database/custom-sql/*.sql |
3 个 | scp |
| MariaDB 数据目录(账号、角色、world 库) | 59M(打包后) | 停库 → tar volume → 解到远程 volume |
数据库是整个数据目录搬的,不是 mysqldump,所以账号(含 account_access
里的 GM 6)、两个角色(Alice 18、Flora 16)、migration 状态全都是原样。
远程首次启动时镜像检测到一条新的 migration edit,按 VMANGOS_ENABLE_AUTOMATIC_WORLD_DB_CORRECTIONS=1
自动重建了 world 库并重跑了那三个 custom SQL——characters / realmd 库不受影响。
主机上还跑着另一个项目(rewindom,占 80/443/3700),3724 和 8085 都是空的,
无防火墙拦截。原本没有 swap,加了 4G(/swapfile,已写进 /etc/fstab)。
实际占用很省:mangosd 280M、realmd 30M、database 155M——mmaps 是按需 mmap 的,
不会一次全进内存。
ssh "$SERVER_SSH_USER@$SERVER_HOST"
cd /opt/vmangos
docker compose ps
docker compose logs -f mangosd
docker compose restart mangosd公钥已经放进 /root/.ssh/authorized_keys,但这台机器 sshd 全局
PubkeyAuthentication no,所以现在还是走密码。要免密:
ssh "$SERVER_SSH_USER@$SERVER_HOST" \
"sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config \
&& sshd -t && systemctl reload ssh"vmangos-deploy 已经 docker compose stop,数据原样留着当后备。
别两边同时开——角色数据会各走各的,之后合不回去。真要回本地:
bin/set-realmlist.sh local + 本地 docker compose up -d,但线上练的进度不会跟回来。
client/ 那个符号链接指向本地的 storage/mangosd/client-data,客户端 MPQ 还在本地,
和服务端搬没搬没关系。
服务端在线上,平时不用管它(restart: unless-stopped,机器重启会自己起来)。
要看状态或重启见上面「管服务器」。
启动客户端:双击 WoW.app(在面板里选语言/画面/服务器),或 bin/play-wine.sh [客户端目录]
| 脚本 | 用途 |
|---|---|
bin/01-unpack-client.sh |
解压客户端 zip 到 client-data |
bin/02a-extract-maps-vmaps.sh |
提取 maps / vmaps / dbc(几分钟) |
bin/02b-extract-mmaps.sh |
提取 mmaps 寻路网格(数小时) |
bin/mangos-console.py |
向 mangosd 控制台发命令(pty attach,安全脱离) |
bin/auth-check.py |
独立 SRP6 客户端,端到端验证认证链路(SERVER_HOST=$SERVER_HOST 验线上) |
bin/set-realmlist.sh |
切两个客户端连的服务器:online / local / 任意地址 |
bin/gmbox-gen-data.sh |
重新生成 GM Box 插件的传送点/物品数据表(末尾会顺带调下面那个) |
bin/gmbox-gen-faction.py |
重新生成 GM Box 的阵营/声望表(读 Faction.dbc,不读世界库) |
bin/patch-faction-dbc.py |
30-cross-faction.sql |
bin/patch-taxinodes-dbc.py |
taxi_nodes 表里 |
bin/play-wine.sh |
用 WoWSilicon 的 Wine 启动指定客户端目录 |
bin/install-addons.sh |
安装/更新那套 1.12 插件(支持 LOCALE= / DEST=) |
launcher/build.sh |
把 launcher/*.swift 编译成 WoW.app(改了启动器要重跑) |
launcher/make-signing-identity.sh |
造本机自签的代码签名身份,让辅助功能授权不被重新编译冲掉(跑一次) |
| 目录 | 语言 | 命令行启动 |
|---|---|---|
client/ |
enUS | bin/play-wine.sh |
client-zhCN/ |
简体中文 | bin/play-wine.sh client-zhCN |
WoW.app 是两个客户端共用的入口(原来的 WoW EN.app / WoW CN.app 已合并进它),
拖进 Dock 或 Finder 侧边栏即可。双击弹出面板,六个分页,底栏一行摘要 + 服务器延迟 + 开始游戏:
| 页 | 项 | 落到哪儿 |
|---|---|---|
| 常规 | 语言 | 决定启动哪个客户端目录,并写 Config.wtf 的 SET locale |
| 服务器 | 线上 / 本地 / 自定义 —— 同时写 realmlist.wtf 和 Config.wtf 的 SET realmList |
|
| 下次直接进游戏 | 存进 launcher.json,见下 |
|
| 画面 | 模式 | 全屏 / 窗口 / 独占全屏。「全屏」有辅助功能授权时:目标是主屏走 macOS 原生全屏(独占一个 Space),目标是副屏走窗口铺满(顶上留 60pt,原因见下);没授权时退化成无边框满屏 |
| 显示器 | 让游戏开在哪块屏。默认「跟着这个面板走」——把面板拖到哪块屏,游戏就开在哪块;面板会开在你上次放它的地方。见下面「跑在副屏上」 | |
| 分辨率 | gxResolution;候选只列主显示器认的档位,别的值客户端会掉回 800x600,见下面「跑在副屏上」 |
|
| 帧率上限 | dxvk.conf 的 d3d9.maxFrameRate(不是游戏 cvar) |
|
| UI 缩放 | uiScale |
|
| 声音 | 四条音量 | MasterVolume / MusicVolume / SoundVolume / AmbienceVolume |
| 高级 | 画质 | 低/中/高/极限一次刷十几个视距、阴影、各向异性 cvar;默认保持不变,不碰手调过的值 |
| Wine Retina 模式 | prefix 注册表的 Mac Driver\RetinaMode |
|
| 原生全屏 | 辅助功能授权状态,没授权时这里能一键去授权 | |
| 同步 | 角色数 | 两端各有几个角色,bin/sync-characters.sh status 现读 |
| 线上 → 本地 / 本地 → 线上 | 整库同步;默认先备份目标端,备份落在 downloads/db-sync/ |
|
| 文件 | 日志 / 客户端 / 插件 / WTF / 截图 | 一键在 Finder 里打开 |
选择存在 launcher.json。勾上「下次直接进游戏」以后双击就直接起飞,按住 ⌥ 双击才回到面板。
几条硬规矩:
- 不要开独占全屏(
gxWindow 0)。Wine 下切回桌面时模式切换会挂死,这是这个客户端唯一 一个稳定复现的死法,游戏里的 Alt+Enter 同理别按。无边框满屏看着一样,Cmd-Tab 还是稳的。 面板里那一项留着是为了完整,选中会有橙字警告。 - 游戏开着的时候面板不给启动,会弹框拦下来。客户端退出时把
Config.wtf整个写回去, 这时候改什么都会被它覆盖 —— 同样的道理见bin/set-realmlist.sh。 - 面板打开时 UI 缩放和音量是从当前客户端的
Config.wtf现读的,不是从launcher.json读的。所以你在游戏里调过的值,下次开面板看到的就是新值,不会被打回去。
启动器本身只是把配置写好,然后 exec bin/play-wine.sh,并且带 WINDOWED=0 让脚本
别再插手那两个窗口 cvar。输出照旧在 /tmp/wow-client.log 和 /tmp/wow-client-zhCN.log。
1.12 的客户端没法被告知用哪块屏。 完整的 cvar 表里只有 gxWindow、gxMaximize、
gxResolution,没有 gxMonitor:
strings -a client-zhCN/WoW_tweaked.exe | grep -oE '^gx[A-Za-z]+$' | sort -u它调 MonitorFromRect,也就是窗口落在哪块屏,就在哪块屏最大化;而 Wine 把窗口摆在
桌面原点,原点永远是主显示器。所以这件事只能在 macOS 那一侧解。启动器里有两条路,
Launcher.plan(_:) 决定走哪条。
① 有辅助功能授权 → 辅助功能 API 摆窗口(默认走这条)
客户端开出来的是个普通 Cocoa 窗口,AXPosition 能挪、AXSize 能改尺寸、AXFullScreen
能全屏。满屏怎么做取决于目标是哪块屏(FillMode):
| 目标屏 | 做法 | 结果 |
|---|---|---|
| 主屏 | AXFullScreen —— macOS 原生全屏 |
铺满,自带一个专属 Space,三指滑就能切进切出 |
| 副屏 | AXSize 把窗口拉到那块屏那么大 |
铺满,但顶上留 30pt 菜单栏 + 32pt 标题栏 |
为什么副屏不能也用原生全屏:会黑屏。 逐项测出来的 ——
主屏 窗口 1512x945 → 被 Cocoa 压到 917(一次 Reset) 有画面
主屏 原生全屏 → Reset 到 1512x949 有画面
副屏 窗口 1352x878 → 不 Reset 有画面
副屏 AX 拉到 1920x1050 → Reset 到 1920x1018 有画面
副屏 原生全屏 → Reset 到 1920x1080 黑(有指针、有声音、CPU 126%)
只有最后一行黑,而且 D3D 的 Reset 本身是能扛的(前四行有三行都 Reset 了)。日志一句
err/warn 都没有,交换链健康,进程满负荷在渲染 —— 黑的只是画面。试过「先把窗口拉到铺满、
让 Reset 在普通窗口状态下发生完,再进原生全屏」,全屏那一步不生效,白跑。
两条路都写窗口 cvar(gxWindow 1 + gxMaximize 0),原因不同:原生全屏那条是因为
Wine 的 adjustFullScreenBehavior: 明确排除 maximized 的窗口,gxMaximize 1 拿不到全屏
按钮;拉尺寸那条是因为 gxMaximize 1 开出来的无边框窗口(WS_POPUP,确实没标题栏)在 Wine
下不可缩放,AXSize 会被静默忽略 —— 实测挪到副屏了、尺寸纹丝不动还是主屏那么大。
顶上那 60pt 边框就是这么来的:无边框和可缩放在这个客户端上二选一。
那 60pt 去得掉,代价是动显示器排列。 副屏一旦是主显示器,
1920x1080就成了合法的gxResolution(为什么,见下一节),gxMaximize 1的无边框窗口从创建那一刻就正好盖住副屏 —— 不缩放、不 Reset、不需要辅助功能授权,也就没有黑屏的触发条件。这正是②那条被删掉的 兜底路,它真的做到过:client-zhCN/Screenshots/里 2026-09-03 上午那四张 TGA 就是 1920x1080(TGA 头写的就是后台缓冲尺寸),当天下午那条路删掉之后,截图分辨率再没超过 1512x982。所以「不动排列」和「副屏完美全屏」在这个客户端上是互斥的,得挑一个。
gxResolution 千万别写成目标屏的尺寸。 客户端建 D3D 设备时只认主显示器那张模式表
(D3D adapter 0),不在表里就直接掉回 800x600 —— 而副屏的原生分辨率几乎注定不在主屏那张表里
(这台机器上内置屏一个 16:9 的档位都没有)。实测:
gxResolution 1512x850 放得进桌面,但不在模式表里 → DXVK: Buffer size: 800x600
gxResolution 1512x945 在模式表里 → DXVK: Buffer size: 1512x945
判据是「在不在表里」,不是「放不放得下」。这张表就是 CGDisplayCopyAllDisplayModes
—— Wine 的 Mac driver 也是从这儿取模式的,RetinaMode 关着按「点」报、开着按像素报,
所以启动器直接照抄(Displays.primaryModes(retina:))。想自己看一眼:
cat > /tmp/modes.swift <<'EOF'
import AppKit
let id = CGMainDisplayID()
let opts = [kCGDisplayShowDuplicateLowResolutionModes as String: true] as CFDictionary
for m in (CGDisplayCopyAllDisplayModes(id, opts) as? [CGDisplayMode] ?? []) {
print("\(m.width)x\(m.height) px \(m.pixelWidth)x\(m.pixelHeight)")
}
EOF
swift /tmp/modes.swift | sort -u好在跑起来的尺寸不靠 gxResolution:客户端会跟着窗口大小 Reset。所以起飞时只要给一个
「开得出来、又不会被 Cocoa 压小」的合法档位(Displays.safeWindowSize),进原生全屏之后它
自己就变成目标屏的尺寸,一比一,不拉伸。同一次实测的完整序列:
gxResolution 1352x878 → Buffer size: 1352x878
Device reset
→ Buffer size: 1920x1080 ← 副屏原生尺寸,AX 全屏之后客户端自己跟上的
找窗口的第一道判据是全屏按钮(只有真正的顶层窗口有 kAXFullScreenButtonAttribute),
但有按钮的不止一个,而 kAXWindows 的顺序是 z-order —— 取第一个就是抽签,抽中杂鱼窗口就会
去挪它、给它全屏,游戏本体留在主屏不动。所以第二道判据是取里面最大的,并且不小于 640x480。
同时开着的杂鱼长这样:
wine bounds=(-210,-1080) 1920x32 ← 副屏的菜单栏条
wine bounds=(0,0) 1512x33 ← 主屏的菜单栏条
wine bounds=(0,482) 500x500 ← 几个隐藏的
wine bounds=(-210,-1080) 1920x1080 ← 游戏本体(name=魔兽世界)
挪窗口也必须回读确认。原来是「设一次 AXPosition,睡 0.5 秒,接着全屏」,那 0.5 秒是猜的:
Wine 处理 WM_MOVE 要多久没有定数,副屏在负坐标区时 macdrv 还会把窗口往主屏拽回去。而且
全屏之后只检查 AXFullScreen == true、不检查在哪块屏 —— 一旦在主屏上全屏成功就返回 true
收工,于是「有时候全屏跑到主屏来」。现在挪完轮询 AXPosition/AXSize 算交集面积确认落位,
全屏后再确认一次屏幕,不对就退出全屏重来。
这条路显示器排列一点都不碰,菜单栏和 Dock 不搬家,游戏自带一个 Space,三指滑就能切进切出。 启动器等窗口出来、挪好、全屏,然后自己退掉。
授权一次就够了。 早先
WoW.app是 ad-hoc 签名,TCC 记的指定要求是一串 cdhash, 二进制一变就失效,每改一次启动器都得重新授权 —— 而且系统设置里那个开关看着还是开的, 看起来一切正常功能就是不灵。现在build.sh改用本机自签证书签名 (launcher/make-signing-identity.sh造一次,不需要 sudo),指定要求只跟证书绑定, 重新编译不掉。「高级」页那一行显示当前状态。
② 没有授权 → 老实在当前主屏开
execv 换掉自己,Dock 上那个图标直接变成游戏。面板会把授权状态摆在「高级」页上。
曾经还有第三条「临时把目标屏设成主显示器」当兜底(CGConfigureDisplayOrigin 是公开 API,
不需要权限),已经删掉:它要把菜单栏和 Dock 都搬到副屏,还得留个进程守到游戏退出才能摆回排列,
代价比它解决的问题大。
「显示器」那一项默认是「跟着这个面板走」,判据就是面板窗口此刻压在哪块屏上 —— 想在哪块屏玩,把面板拖过去再点开始游戏。眼睛看得见,不用猜。
取窗口的屏要用
keyWindow/mainWindow,别用「第一个可见窗口」碰运气;而且得用NSApplication.shared而不是NSApp—— 跳过面板那条路上 AppKit 还没起来,NSApp是 nil, 碰一下就崩。
面板记住上次摆在哪(main.swift 的 PanelFrame,存 UserDefaults)。不然这一项还是不稳:
NSWindow.center() 落在 NSScreen.main ——「当前有键盘焦点的窗口在哪块屏」,每次开都可能不一样。
存了之后把面板拖到副屏一次,以后每次都开在副屏,游戏也就每次都在副屏全屏。
没用 AppKit 自带的
setFrameAutosaveName/setFrameUsingName。 它存的是「窗口原点 + 那块屏的visibleFrame」,而visibleFrame里含菜单栏,菜单栏在哪块屏是随前台 App 变的: 面板存在内置屏时那块屏是1512x949,下次启动前如果前台 App 在副屏,内置屏就报1512x982, 对不上,setFrameUsingName直接返回false,窗口又回去抽签了 —— 实测出来的,不是猜的。 另外setFrameAutosaveName一调就立刻把当前 frame 存一次,先设名字再读等于读自己刚写进去的值。自己存的是「原点 + 那块屏的
frame」,frame不含菜单栏、不随前台变。恢复时按frame找回 那块屏,再把原点夹进它当前的visibleFrame;那块屏不在了就退回center()。
勾了「下次直接进游戏」时根本没有面板,没有窗口可问,这时候退回启动器进程刚起来那一刻鼠标
所在的屏(= 你双击图标的那块屏)。它必须在任何窗口出现之前抓
(Displays.captureLaunchDisplay(),main.swift 第一行)—— 面板一摆出来,鼠标就跟着
用户去点按钮了,再问就晚了。
单屏时它和「当前主显示器」完全等价。
试过但走不通的两条:Wine 虚拟桌面(explorer /desktop=)—— WoWSilicon 这个 Wine 里
explorer.exe 自己就崩(SEH 异常);自己调私有 API 建 Space(CGSSpaceCreate 那套)——
必须从 Dock 进程里调,要求关掉 SIP。
纯手工也随时能用:游戏窗口是 layer 0 的普通层级窗口,按 F3 打开调度中心拖到别的桌面, 或者 Dock 图标右键 → 选项 → 指定给。
launcher/build.sh 重新编译(要 Xcode 的 swiftc,会自动收 launcher/*.swift),
产物直接覆盖 WoW.app。源码分五块:Model.swift(设置与 Config.wtf 读写)、
Displays.swift(显示器枚举、主适配器模式表、起飞尺寸)、Native.swift(辅助功能 API 挪窗口 / 拉尺寸 / 原生全屏)、
Launch.swift(LaunchPlan 与起飞的三条路)、ContentView.swift + main.swift(面板与入口)。
为什么是分页而不是折叠区:Form 的 .grouped 样式自带一个 ScrollView,内容一多
就冒滚动条,折叠区展开的瞬间尤其难看。现在五页各自高度写死(ContentView.pageHeight),
用 Form 的默认样式(只对齐标签列、不滚动),窗口尺寸自始至终不变,结构上就没有可滚的东西。
代价是往页里加控件得重新量一次高度:
WOW_MEASURE=video WoW.app/Contents/MacOS/WoWLauncher # general|video|audio|advanced|folders只画那一页、去掉高度约束,窗口高度减 32(标题栏)就是它的自然高度,五页取最大的填回
pageHeight。同理,选项的出现/消失会改高度,所以自定义地址框是常驻变灰的,显示模式那句
说明用 lineLimit(2, reservesSpace: true) 占死两行。
这台机器没给终端屏幕录制权限,screencapture 拿不到窗口图像。两条替代路子,都不需要那个权限:
量布局用辅助功能 API。 AXUIElementCreateApplication(pid) 把每个控件的 role、坐标、
尺寸、文本读成数字 —— 标签列有没有对齐、说明折在第几个字、卡片是不是等高,比肉眼准。
本仓库的分页布局(标签列 62pt、控件从同一条竖线起、卡片精确等高 58)全是这么调出来的。
看渲染让 app 自己截自己。 进程内 cacheDisplay / CALayer.render 不走屏幕录制权限。
launcher/main.swift 里埋了个钩子:
WOW_SNAPSHOT=/tmp/panel.png WoW.app/Contents/MacOS/WoWLauncher &
kill -USR1 <pid> # 视图 cacheDisplay -> /tmp/panel.png
kill -USR2 <pid> # 层树 CALayer.render -> /tmp/panel.png.layer.png用信号而不是定时器,是为了能抓「鼠标正按着」那一帧:外面先用 CGEvent 把鼠标按下去
(需要辅助功能授权,已经有了),再发信号。两种抓法都留着 —— 结论一致才可信。
launcher/Slider.swift 的 KnobSlider。这个 macOS 上,滑块被按住时圆钮整个不画,
只剩一条轨道,看着像「透底」。用上面那套抓帧做的对照实验:
| 写法 | 按住时圆钮 |
|---|---|
最朴素的 Slider |
消失 |
AppKit 原生 NSSlider |
消失 |
加不透明背景 / compositingGroup() / 换 controlSize |
正常(没按而已) |
按最右端和按中间一样,不是被裁掉;cacheDisplay 和 CALayer.render 两种抓法结论一致。
既然 NSSlider 也中招,应用侧换控件实现就没有出路了,只能自己画。
尺寸和配色是从系统滑杆的截图上逐像素量的(深色模式 2x):轨道高 6pt;已填充
#257DFA(就是 accent);未填充 #343434(底色 #1E1E1E,约 primary.opacity(0.12));
圆钮 19×16pt 胶囊(不是正圆)、#DDDDDD、下方约 4pt 很淡的阴影。浅色模式下系统圆钮
是白的,所以按 colorScheme 换。
WOW_MEASURE=slidertest 是留着的对照页:系统滑杆和自绘滑杆并排,随时能再验一次。
图标由 launcher/make-icon.swift 生成,源图是 launcher/emblem.png(那枚圆徽章,
四角透明)。它把徽章摆到一块 Apple 标准比例的 squircle 底板上(1024 画布 / 824 底板 /
185.4 圆角),再按整套尺寸阶梯逐个渲染 —— 底板和金边是矢量的,小图不是大图缩出来的。
原来那份 .icns 只有一张 256x256 的满幅方图,Retina 下糊,形状也跟别的 Dock 图标不是一套。
make-icon.swift 或 emblem.png 一改,build.sh 会自动重新生成。
两份是同一个游戏、同一个二进制(WoW.exe 逐字节相同),只有 Data/ 的 MPQ 不同。
中文那份的说明见 client-zhCN/README.md。
插件是共享的:两边的 Interface/AddOns 都是指向 addons/ 的符号链接,
装一次两个客户端都有,也不会各自漂移。可行是因为 Blizzard 的存根(Blizzard_*/*.pub)
两边 diff -rq 完全相同,而 pfQuest 的中文版同时带 db/enUS 和 db/zhCN、运行时按
GetLocale() 选,所以一份装两处都对。
不能共享的:Data/ 的 MPQ 每一个尺寸都不同(连 model/texture 都不一样——国服有和谐
化模型),WTF/ 里 SET locale 不同、账号状态也各自独立。
语言不能在游戏里切——vanilla 启动时由 Config.wtf 的 SET locale 决定,换语言就是
换目录启动。服务端会跟着客户端上报的语言自动发中文任务/物品/NPC 文本。
1.12 的 WoW.exe 里资源补丁槽是 patch-?.MPQ——单字符通配,所以 patch-3 到
patch-9、patch-A 到 patch-Z 全都会被加载,字符越靠后加载越晚、优先级越高。
原版只占了 patch.MPQ 和 patch-2.MPQ,其余全空。
已装 Koward/Improved_Models v2.1
(上游发布时叫 patch-3.MPQ,这里改名放进 patch-A.MPQ,把数字槽留给别的东西):
url https://github.com/Koward/Improved_Models/releases/download/v2.1/imp_standard.zip
sha256 32e4b2882d298b0ed6dcf9519a6ded449c260c55f66580a07921495f2e8031bf (zip)
360e62ebd0a834a2d7b430a6b7de119975458a1cb137d6d623359b886b78b921 (MPQ)
装在 client/Data/patch-A.MPQ 和 client-zhCN/Data/patch-A.MPQ
内容用 mpyq 逐条核过:254 个文件,只有 198 个 .blp 和 56 个 .m2, 没有 DBC、没有 WMO、没有 ADT——也就是说不可能和服务端的数据对不上。改的是 德鲁伊变形(熊/豹)、狼、猛禽、螃蟹、鳄鱼、蝙蝠、雪人这些生物模型,暴风城的喷泉 和店铺招牌、艾尔文和荆棘谷的树、副本贴图,外加几个界面按钮。
卸载就是 rm client/Data/patch-A.MPQ client-zhCN/Data/patch-A.MPQ。
两个注意点:
client/是指向vmangos-deploy/storage/mangosd/client-data的符号链接,也就是 服务端提取 maps/vmaps/mmaps 时读的那份Data/。这个补丁里有.m2,vmapextractor会读 m2 做碰撞体。要重跑bin/02a-*/02b-*之前先把patch-A.MPQ挪走, 免得树和招牌的碰撞跟着变(影响极小,但没必要引入差异)。- 国服客户端有和谐化模型,这个包是国际版模型,装上去等于把碰到的那几个(雪人、 恐惧魔王、troll 之类)换回原版形象。想要哪种自己取舍。
这一层很不直观,先说清楚加载链是怎么走的:
WoW_tweaked.exe
└─ (PE 导入表,原版就有) DivxDecoder.dll ← 被 VfPatcher 改过 40 字节,
└─ LoadLibraryA("libDllLdr.dll") 塞了个 code cave
└─ 读 client/dlls.txt
└─ 逐个 LoadLibrary 表里的 DLL
关键点一:这一层不是共享的。 插件靠 Interface/AddOns 符号链接两个客户端共用,
但 dlls.txt、DivxDecoder.dll、libDllLdr.dll、各个 mod DLL、WoW_tweaked.exe
每个客户端目录各有独立一份。装 DLL mod 必须 client/ 和 client-zhCN/ 两边都装,
只装一边的话另一边悄无声息地什么都不会发生。两边的 WoW.exe 逐字节相同,所以
WoW_tweaked.exe 可以打一次直接复制过去。
关键点二:补丁烧在 DivxDecoder.dll 里,跟用不用 VanillaFixes.exe 当启动器无关。
macOS 下 bin/play-wine.sh 是直接起 exe 的(rosettax87 已经处理了 x87/RDTSC,不需要
VanillaFixes 的时序修正),所以 VanillaFixes.exe 本身是死的——但 dlls.txt 照样生效。
DivxDecoder.dll.bak 是打补丁前的原件。
client/dlls.txt 当前内容:
| DLL | 作用 |
|---|---|
wow_turbo.dll |
性能 |
mods/winerosetta.dll |
Wine 桥接,Apple Silicon 上跑起来的前提 |
mods/libSiliconPatch.dll |
Apple Silicon 指令优化 |
SuperWoWhook.dll |
SuperWoW 2.2:真实 unit GUID、SpellInfo()、UNIT_CASTEVENT |
nampower.dll |
nampower 4.6.1:法术排队 |
顺序有意义,SuperWoW 要在 nampower 前面。
SuperWoW — pfUI 自带整套兼容层(modules/superwow.lua),装上才不是死代码。单人向的
实际收益是所有单位都有施法条,不只是当前目标:打怪时能看见对方在读什么,好打断、好躲。
不需要 SuperWoWlauncher.exe,走 dlls.txt 就行。
nampower — 修 1.12 的施法排队,消掉"法术还没准备好"的空转,手感提升最直接。
注意两条:① 它和 pfUI 的 mouseover 宏有已知时序 bug,只有装了 SuperWoW 才修好,
所以这两个是配套的;② 上游 gitea.com/avitasia/nampower 已经消失,现用的是
brues-code/nampower v4.6.1——该 release 由 github-actions[bot] 从 tag sha 2c7f51c
的 CMake workflow 构建上传,源码公开可对照,不是手工传的二进制。
配置:全部通过 CVar,/run SetCVar("NP_SpellQueueWindowMs","1000"),或直接写进
WTF/Config.wtf。启动后 client/Logs/nampower_debug.log 会列出所有生效的 CVar,
这也是验证它加载成功最快的办法。SuperWoW 没有日志,进游戏后用
/run DEFAULT_CHAT_FRAME:AddMessage(SUPERWOW_VERSION or "未加载") 确认。
dlls.txt 里原本有一条规矩:只收性能和 bugfix,不要任何改变战斗时序、距离、目标选择
或信息量的东西。2026-09-03 有意废除——这是本地单机服,账号是 ADMIN,装着 GMBox
可以随时传送和刷任何东西,不存在需要对谁公平。判断标准换成了:solo 玩着爽不爽。
client/vanilla-tweaks.exe(brndd,v1.6.0)把补丁打进 exe,产出 WoW_tweaked.exe,
和上面的 DLL 层是两回事。当前是默认全开 + 手动开了 max camera distance:
cd client && wine vanilla-tweaks.exe --maxcameradistance 100 WoW.exe默认开的九项:宽屏 FoV 修正、后台出声、声道数 12→64、farclip 上限、草地距离、
快速拾取(按 shift 手动拾取)、姓名板距离 20→41 码、Large Address Aware、镜头跳转 bug 修复。
只有 max camera distance 默认不开,所以要显式给参数。打完用
/console CameraDistanceMax 100 才真正拉远。
改完 exe 记得 WoW.exe 本身没动,随时可以重打。重打时记得把
--maxcameradistance 100 也带上——它是 vanilla-tweaks 的参数,不带就退回 50。
bin/patch-itemc-nullguard.py 是在 vanilla-tweaks 之上再打的一处手工补丁,
bin/play-wine.sh 每次启动会自动重打(已经打过就静默,出任何意外都会在终端出声,
但绝不挡启动),所以重跑 vanilla-tweaks 之后不用管它。
补的是 0x004C7EE0 那个装备槽标志刷新函数:它无条件写 [0xB71F60] 指向的
12 个 DWORD,而那个全局指针在某些时机是 NULL,AoE 拾取时会 ERROR #132 ACCESS_VIOLATION at 0x004C7F1B 崩掉客户端(2026-09-04 在哀嚎洞穴中过一次,
崩溃时的存档还把半成品物品寄了出来)。补丁在函数入口加一个空指针检查,
是空就直接返回。原始 exe 备份在 WoW_tweaked.exe.pre-itemc-nullguard。
装在 client/Interface/AddOns/。只有为 1.12 写的插件能用——Questie、DBM、Bagnon、
Auctionator、大脚/BigFoot 这些是 Classic Era(1.13+)的,调用的 API 在 1.12 里根本不存在,
勾"载入过期插件"也没用。下表是逐个核对过 ## Interface: 11200 的一套。
因为是单机本地服,团队向的东西一律不装:仇恨表、稀有怪扫描(unitscan)都没有意义 ——想要什么直接用 GM 命令刷或者传过去。2026-09-03 又按玩法(旅行、观光、做任务, 外加偶尔刷本)砍了一轮数值向的件,见下面「按玩法裁掉的」。
| 插件 | 打开 | 作用 | 来源 |
|---|---|---|---|
| pfQuest 7.0.1 | /db |
任务指引 + NPC/物品/物件数据库,地图和小地图标点。1.12 的事实标准,Questie 的位置 | shagu/pfQuest |
| Bagshui 1.0.5 | /bs |
合并背包 + 自动分类,Bagnon 的位置 | absir/Bagshui(镜像,作者 veechs) |
| Atlas / AtlasLoot / AtlasQuest | /atlas /al |
副本地图、Boss 掉落表、副本任务 | Cabro/Atlas(1.12 backport) |
| SuperMacro | /smacro |
突破宏长度限制、宏库、/run 辅助 |
Monteo/SuperMacro |
| GM Box | /gm /gt /gi /gr |
自制 GM 面板(传送 / 物品 / 声望 / 工具),见 client/Interface/AddOns/GMBox/README.md |
本仓库 |
| CleverMacro | 宏编辑器 | 给 1.12 补条件宏([mod:alt] [harm] [stance]),和 SuperMacro 互补不冲突 |
DanielAdolfsson/CleverMacro |
shagu/pfUI 是 1.12 的整套界面替换(头像框、动作条、背包、聊天、姓名板、小地图),
vanilla 时代 ElvUI 的位置,和上面那些出自同一个作者。已装,pin 在 b2f6df8。
它自带冲突处理(modules/addoncompat.lua):首次登录扫描已启用的插件,发现功能
重复的就弹框问你要不要关,选择记在 pfUI_init.addons 里不会重复问。它的软冲突表里
点了这几个的名:
ShaguPlates ShaguTweaks ShaguBoP ShaguError ShaguMount ShaguValue
2026-09-03 这七个(含 ShaguTweaks-extras)已经从磁盘删除,不是关掉——功能 pfUI
全都有(nameplates / sellvalue / autoshift / loot / 错误屏蔽),留着只会让 pfUI
每次登录都来问一遍。ShaguPlates 尤其没有留的道理,它本来就是 pfUI 姓名板模块导出的
独立版,两个一起开会抢同一批框体。
bin/install-addons.sh 里这七条改成了注释,URL 和 commit 都还在。真想退回默认 UI,
把注释里的条目恢复、再关掉 pfUI 即可;备份也在 addons-removed-20260903-083503.tar.gz(按玩法裁掉的那批
在 addons-removed-20260903-102345.tar.gz)。
pfUI 不认识 Bagshui(不在它的冲突表里),所以两个背包插件默认会同时开。
Flora 上已经处理过了:pfUI_config.disabled.bags = "1",pfUI 的背包模块关掉,
背包归 Bagshui 管(要它的自动分类)。新角色第一次登录得自己在 /pfui 里勾一次
Disable Module bags,或者反过来在角色界面关掉 Bagshui 用 pfUI 自带的背包。
这些不在 pfUI 的冲突表里,是纯增量:
| 插件 | 作用 |
|---|---|
| ShaguNotify | 成就式弹窗:升级、学会新技能、拿到好东西。vanilla 没有成就系统,这个补那股仪式感 |
| ShaguKill | 还差几只怪升级 |
| pfQuest-icons | pfQuest 的采集点换成 Gatherer 图标 |
玩法是旅行、观光、做任务,外加偶尔刷本,所以 2026-09-03 把只在"看数字"时才有用
的那半边拆了。都是注释掉不是删条目,bin/install-addons.sh 里 URL 和 commit 都还在,
文件夹备份在 addons-removed-20260903-102345.tar.gz。
| 拿掉 | 为什么 | 装回 |
|---|---|---|
| ShaguDPS | 伤害统计。solo 打怪没人跟你比 | 刷本想看输出就装回来 |
| ShaguScore | 装等评分。GMBox 能直接刷任何装备,评分没有意义 | |
| ShaguInventory | 跨角色持有数量。在玩的只有一个号 | |
| BetterCharacterStats | 法伤/命中/暴击面板。观光路上不看这些 | 认真配装时装回来 |
| ItemRack | 装备套装切换。一套穿到底 | |
| ATSW | 制造队列。采集不需要制造窗口 | 认真做专业时装回来 |
| pfStudio | 游戏内 Lua IDE。开发工具,不是游戏插件 | 改 GMBox 时装回来 |
bin/install-addons.sh ShaguDPS # 单独装回某一个Atlas 三件套(Atlas + AtlasLoot + AtlasQuest)留着——副本还是要刷的,地图、Boss 掉落表、副本任务三样都还在用。真正砍掉的只有团队规模和纯数值的东西。
bin/pfui-travel-preset.py 把一套偏观光的设定盖到 pfUI 配置上。pfUI 的
pfUI_config 是按角色存的(WTF/Account/<账号>/<realm>/<角色>/SavedVariables/pfUI.lua),
所以每个角色都得盖一次;不给参数就是全客户端全角色都盖。
bin/pfui-travel-preset.py --dry-run # 先看会改什么
bin/pfui-travel-preset.py # 真改,原文件留一份 .pre-travel-preset客户端必须先完全退出。 WoW 退出登录时会整份重写 SavedVariables,游戏开着改等于白改。
改的 16 项:
| 项 | 改成 | 为什么 |
|---|---|---|
worldmap.mapreveal + mapexploration |
开 | 最值的一条:世界地图把没去过的区域地形也画出来,还标出探索点。地图从一片黑变成可以拿来计划下一趟走哪 |
minimap.size |
140 → 170 | 客户端跑在 2560x1440,140 太小 |
minimap.zonetext / coordstext |
常驻 | 区域名和坐标一直挂着,不用鼠标悬停;配合 pfQuest 找点 |
questlog.showQuestLevels |
开 | 任务列表显示等级 |
panel.left.right |
friends → bagspace | 单机服没有好友列表可看,换成背包剩余格子 |
screenshot.levelup / loot / caption / hideui |
开 / 紫装 / 开 / 开 | 旅行日志:升级和吃到紫装时 pfUI 自动截图,截之前把整个 UI 藏掉,照片上打时间戳和「大区 - 小区」。见 pfUI/modules/screenshot.lua |
disabled.raid |
关模块 | solo 不开团。5 人本走的是 group 模块,不受影响 |
disabled.targettargettarget |
关模块 | 目标的目标的目标,团本解析用的 |
disabled.updatenotify |
关模块 | 离线服,不用查 pfUI 更新 |
border.shadow |
开 | 边框加投影,边界更清楚 |
global.autosell |
开 | 进商人自动卖掉灰色。战斗灰已经在尸体上变成铜币;剥皮和钓鱼留下的灰走这一条 |
动作条一个都没动。查过服务器 character_action 表:Flora 在 bar1/3/4/5/6 上一共
放了 31 个按钮,Alice 29 个——那几条条不是摆设,关掉会让按钮点不到。想少几条条得先
自己把技能挪一挪,挪完再 /pfui 里关。
顺手值得知道的两个:
/farm—— pfUI 的采集模式。小地图铺开放大、其余 UI 全隐藏,pfQuest 的采集点直接 画在上面。赶路和找草药矿点时按一下,再按一下回来。- Alt+Z —— vanilla 原生的隐藏全部 UI,纯手动截图用。
这些是 CVar 不是 UI,但对"看风景"影响比任何插件都大。在游戏里用 /console 调,
别直接改 Config.wtf——客户端退出时会把内存里的值整份写回去,覆盖手改的内容。
/console farclip 777 # 视距,当前 477。vanilla-tweaks 已经解掉了上限
/console frillDensity 128 # 地面草的密度,当前 24
/console groundEffectDist 200 # 草的绘制距离,当前 100
/console doodadAnim 1 # 场景物件动画(旗帜、树叶、水车),当前关
/console shadowlod 1 # 阴影,当前 0
前三条吃帧数,一条一条加、看着帧数调。cameraDistanceMax 100 和
cameraDistanceMaxFactor 5 已经在 Config.wtf 里了(靠 vanilla-tweaks 解锁,见上面
那节),镜头能拉得很远,观光就靠这个。
重装或换新客户端时:
bin/install-addons.sh # 全部
bin/install-addons.sh pfQuest # 只装某几个脚本把每个来源钉在验证过的 commit/tag 上,装完会打印各自的 ## Interface 行;
它还会去掉 TOC 开头的 UTF-8 BOM(AtlasLoot 就带 BOM,不去掉的话 1.12 读不到
## Interface 行,插件会被当成过期件禁用)。
1.12 客户端默认给插件 Lua 的内存上限是 48 MB,pfQuest 一个就吃得差不多,超了会弹
"The user interface is using more than 48MB of memory"。已在 client/WTF/Config.wtf 里写入:
SET scriptMemory "0" # 0 = 不限制
(等价操作:角色选择界面 → AddOns → 把 Script Memory 调成 0。客户端退出时会把该值写回
Config.wtf,所以两条路一样持久。)
新插件要完全重启客户端才会被枚举,/console reloadui 不行。
bin/mangos-console.py "account create 用户名 密码"
bin/mangos-console.py "account set gmlevel 用户名 6"bin/auth-check.py <账号> <密码>一个人玩,凑不齐 40 人,也做不了要多人才能推进的前置。下面这些把服务端里
"人数 / 军衔 / 前置" 类的门槛全部拆掉。分两层:能在配置文件里关的走
config/mangosd.conf,写死在数据库里的走 storage/database/custom-sql/。
| 项 | 原值 | 现值 | 作用 |
|---|---|---|---|
Instance.IgnoreRaid |
0 | 1 | 不用组成团队就能进团本。核心的一条 |
Instance.IgnoreLevel |
0 | 1 | 忽略副本的最低等级要求(奥妮 50、纳克 51 等) |
Instance.PerHourLimit |
5 | 100 | 每账号每小时进副本次数。不能填 0——AccountMgr::CheckInstanceCount 里 0 表示"一个都不许进",不是无限 |
Quests.IgnoreRaid |
0 | 1 | 组成团队时也能做普通任务 |
MinPetitionSigns |
9 | 0 | 公会签名数。买了公会注册表直接交给公会管理员就能建会。副作用:0 个签名即"已满",所以别人也签不了(单机无所谓) |
MailDeliveryDelay |
3600 | 0 | 给自己小号寄东西不用等一小时 |
Item.PreventDataMining |
1 | 0 | 允许查询没拿到过的物品,AtlasLoot 里的物品链接才点得开 |
MaxPrimaryTradeSkill |
2 | 9 | 主专业上限。原版 9 个(炼金 / 锻造 / 附魔 / 工程 / 草药 / 制皮 / 采矿 / 剥皮 / 裁缝)。烹饪、急救、钓鱼是副专业,本来就没有人数上限 |
改完要 docker compose restart mangosd。备份在 config/mangosd.conf.bak.*。
InitPrimaryProfessions() 每次登录都会按这个值重算空位,再减去已经学会的主专业,所以已有角色不用新建——重启后重新登录就能去训练师学第三个。1.12 客户端的技能面板和法术书会列出所有已学专业;训练师按钮灰不灰是服务端看剩余空位,不是客户端写死两人。
没动但可以考虑的:AllFlightPaths(0 → 1 直接开全部飞行点)、StartPlayerLevel。
配置项管不到的部分。这个目录里的 .sql 每次数据库容器启动都会按文件名顺序重跑
(所以写的语句都是幂等的),世界库被重建也不会丢。
| 改动 | 说明 |
|---|---|
areatrigger_teleport.required_condition = 0(2848 / 3528 / 3529 / 4008 / 4010 / 4055) |
奥妮克希亚(暗炎项链)、熔火之心(钥石任务)、安其拉废墟 + 神殿、纳克萨玛斯(前置任务 9378)的入口前置 |
gossip_menu_option.condition_id = 0(5750/0、6001/0) |
洛索斯·裂隙行者的"传送到熔火之心"、黑翼之巢入口的命令宝珠(原需任务 7761) |
variables 30050 = 12 |
战争物资阶段推到 WAR_EFFORT_STAGE_COMPLETE,安其拉之门视为已开。等同 VMaNGOS 官方的 AQ-SET_GATES_OPEN.sql |
areatrigger_teleport.required_condition = 0(2527 / 2532) |
荣誉大厅 / 冠军之厅原需 PvP 军衔 R6 |
安其拉之门那条不能用 UPDATE game_event SET disabled = 1 WHERE entry = 83——
硬编码的 WarEffortEvent 每个 tick 都按 variables 里的阶段号把事件 83 重新
EnableAndStartEvent,手动禁用撑不过一次 tick。改阶段号才是唯一稳的做法。
战场出口和侏儒区传送器的 condition 是阵营判断,故意没碰。
恢复原样:删掉 20-solo-raid-access.sql,把同目录的
revert-solo-raid-access.sql.example 改名成 .sql 跑一次。
拆旅行路上的家务,不拆路的质地。
| 改动 | 说明 |
|---|---|
战斗掉落里的灰色按售价折进 gold_min / gold_max,再从 creature_loot_template 拿掉 |
尸体写铜币,不占格子。任务灰、剥皮灰、钓鱼垃圾留下 |
playercreateinfo_item 每个种族/职业 4 个 Large Knapsack(1725,12 格) |
只影响新号。Alice / Flora 已经是无底包,没动 |
| 旅店老板卖弹药和施法材料(含轻羽毛) | 四个本来没有商人位的旅店补上 VENDOR。只记录新插的行,恢复时不误删面包 |
修理不必再打折:DurabilityLoss.Enable 已经是 0。任务赏金也不抬——灰色变成铜币之后,那截钱还在尸体上。
剥皮和钓鱼留下的灰,进商人时由 pfUI global.autosell 卖掉。
恢复原样:删掉 50-solo-travel-chores.sql,把同目录的
revert-solo-travel-chores.sql.example 改名成 .sql 跑一次。
config/mangosd.conf的Rate.Creature.Elite.*.SpellDamage下调storage/database/custom-sql/10-solo-friendly-creatures.sql:所有生物的 生命/近战伤害倍率压到普通怪的 p90(精英、稀有精英、世界 BOSS 一视同仁), 恢复见revert-solo-friendly-creatures.sql.example
| 情况 | 办法 |
|---|---|
| 黑翼之巢的正门(命令宝珠)在黑石塔上层深处,要先把黑石塔一长串路走完 | 上面的 SQL 只解开了宝珠的任务前置;懒得走就直接 .tele bwl |
| Boss 机制本身要多人(勒什雷尔的控制宝珠、四骑士等) | 只能 GM 手段绕过 |
服务端里已经有全部团本的传送点,GM 账号直接用:
.tele mc .tele bwl .tele onyxia .tele zg
.tele aq20 .tele aq40 .tele nax
副本进度锁(MC/BWL/AQ40/纳克 7 天,奥妮 5 天,ZG/AQ20 3 天)没改,想重刷用
.instance unbind all,或从控制台 server resetallraids。
部落角色可以走进暴风城、铁炉堡、达纳苏斯,城卫和 NPC 不动手,任务照接照交, 奖励拿到手能用。反过来也一样(联盟进奥格瑞玛等)。
VMaNGOS 的 WorldObject::GetFactionReactionTo()(src/game/Objects/Object.cpp)
里有这么一段:只要 NPC 所属阵营 CanHaveReputation()——也就是
Faction.dbc 的 reputationListID >= 0——敌对与否就完全由玩家对该阵营的
声望等级决定,FactionTemplate.dbc 那套 alliance / horde 掩码整段被跳过。
主城 NPC、城卫、任务发布者用的都是有声望条的阵营(暴风城 72、铁炉堡 47、
达纳苏斯 69、侏儒 54)。所以只要把"部落种族对暴风城"的起始声望从 -42000(仇恨)
抬到 0(中立),整座城自动就不动手了。挨个改几千个 NPC 的 creature_template.faction
是白费力气,还会把守卫对真正敌人的反应一起弄坏。
客户端一个字节都不用改。 1.12 客户端算名牌颜色和右键光标,用的是服务器下发的
SMSG_INITIALIZE_FACTIONS(standing + flags),不是本地 DBC 里的初始值。
⚠️ 2026-09-07 的重大更正:阵营数据不在 DBC 里,在世界库里。 VMaNGOS 为了同时支持多个客户端 build,把阵营、阵营模板、航点全搬进了世界库 (faction/faction_template/taxi_nodes,每张表都带一个build列, 服务端取「不超过客户端 build 的最新一行」)。ReputationMgr从头到尾没碰过Faction.dbc:void ReputationMgr::Initialize() { for (auto const& itr : sObjectMgr.GetFactionMap()) { // <- 世界库 newFaction.Flags = GetDefaultStateFlags(factionEntry); void ReputationMgr::LoadFromDB(...) { FactionEntry const* e = sObjectMgr.GetFactionEntry(...); // <- 世界库 ... if (GetRank(e) <= REP_HOSTILE) SetAtWar(faction, true); // 起始 -42000 = 仇恨 = 每次登录重新开战最后那句是关键:只要
faction表里起始声望还是 -42000,每次登录都会重新按上 开战位,在character_reputation里手动清成停战只能撑到下一次登录。所以 2026-09-07 之前这一整节都是错的 —— DBC 补丁打了,服务端根本不看,联盟 NPC 照打。 真正生效的是30-cross-faction.sql第 7 段(阵营)和第 8 段(航点);两个bin/patch-*-dbc.py保留下来只是让提取出来的 DBC 和数据库不各说各话, 单跑它们没有任何效果。
30-cross-faction.sql 第 7 段(以及和它内容一致、但不起作用的
bin/patch-faction-dbc.py)—— 13 个声望槽的
base 从 -42000 改成 0、去掉 FACTION_FLAG_AT_WAR:
| 阵营 | id | flags 结果 |
|---|---|---|
| Stormwind / Ironforge / Darnassus / Gnomeregan Exiles | 72 / 47 / 69 / 54 | VISIBLE |
| Orgrimmar / Thunder Bluff / Darkspear / Undercity | 76 / 81 / 530 / 68 | VISIBLE |
| Wildhammer Clan(辛特兰) | 471 | VISIBLE |
| Wintersaber / Ravasaur Trainers(阵营限定坐骑) | 589 / 630 | VISIBLE |
| Alliance / Horde(隐藏的总阵营) | 469 / 67 | 保持隐藏,只去掉开战位 |
flags 用 VISIBLE 而不是 PEACE_FORCED,是为了让这些阵营出现在你的声望面板里:
可以刷、可以换坐骑,也可以随手宣战——有部落任务要杀联盟兵的时候用得上,
点一下就打得动,打完再停战。
storage/database/custom-sql/30-cross-faction.sql —— 数据库这半边:
| 改动 | 行数 | 说明 |
|---|---|---|
quest_template.RequiredRaces = 0 |
696 | 332 个联盟专属 + 309 个部落专属 + 各族起始任务链 |
item_template.allowable_race = -1 |
1769 | 不放开的话交完任务奖励是灰的。坐骑也一起放开 |
creature_template.trainer_race = 0 |
32 | 主要是各族坐骑训练师,不放开会说"我没什么可教你的" |
character_reputation.flags &= ~AT_WAR |
14 | 已有角色存下来的开战位,见下一节 |
game_graveyard_zone.faction = 0 |
80 | 不然死在对方地图要横穿大陆跑尸 |
creature_template.faction 换模板 |
498 | 没有声望条的那批阵营,见下一节 |
faction 起始声望 / flags |
9 行 × 4 档 | 真正让 NPC 不动手的那一条,见上面的更正 |
taxi_nodes 补 mount 槽 |
62 | 对方阵营的飞行管理员才认得出当前航点 |
阵营坐骑的声望门槛(崇敬)保留没动——现在部落也刷得动暴风城声望,正好当个目标。
战场阵营(奥山霜狼 729 / 石锤 730、战歌 889 / 890、阿拉希 509 / 510)故意没碰, 碰了 BG 会坏掉。安其拉、木喉、辛迪加、血帆这些两边都仇视的中立阵营同理。 战场出口和侏儒区传送器的 condition 也还是阵营判断,仍然没碰。
Faction.dbc 那半只对有声望条的阵营管用。VMaNGOS 判反应是两条分支:
if (FactionEntry const* e = sFactionStore.LookupEntry(pTemplate->faction))
if (e->CanHaveReputation())
return pPlayer->GetReputationMgr().GetRank(e); // 走声望
// 否则:FactionTemplate 的 hostileMask & 对方的 ourMask暴风城/铁炉堡/达纳苏斯/诺莫瑞根走上面那条,所以城里的卫兵和 NPC 早就不动手了。
但另有一批联盟 NPC 挂的阵营没有声望条(reputationListID = -1),走的是下面
那条掩码分支,掩码里写死「敌视部落组」—— 声望刷到崇拜也没用。这就是
「进了联盟地盘还是被打」剩下的那一半,一共 1894 只:
| 阵营 | 生物数 | 在哪 |
|---|---|---|
| 189 Alliance Generic | 720 | 联盟所有区域的通用 NPC |
| 61 Dalaran | 481 | 奥特兰克那个魔法罩子 |
| 108 Theramore | 441 | 尘泥沼泽塞拉摩岛整座城 |
| 71 Hillsbrad Militia | 120 | 南海镇民兵 |
| 269 Silvermoon Remnant | 81 | 辛特兰奎尔丹尼小屋的高等精灵 |
| 49 Human, Night Watch | 16 | 夜色镇守夜人 |
修法(30-cross-faction.sql 第 6 段):把 creature_template.faction(存的是
FactionTemplate id,不是阵营 id)换成一个掩码和 flags 完全相同、但阵营有
声望条的模板。只有 faction 那一个字段变,ourMask / friendlyMask /
hostileMask / enemy[] / friend[] 一个字节没动,所以 NPC 之间打不打架完全不变
—— 这几个阵营 id 在整张 FactionTemplate.dbc 里除了自引用没有第三方引用,查过了。
为什么不是直接改 FactionTemplate.dbc 的 hostileMask:那样服务端确实不打你,
但客户端算名牌颜色用的是它自己那份 FactionTemplate.dbc —— 服务端只下发
UNIT_FIELD_FACTIONTEMPLATE 这个 id。名牌照样红,右键就是砍人不是对话,任务还是
接不了。换 id 两边同时生效:客户端拿到新 id,查到一个有声望条的阵营,再套上
SMSG_INITIALIZE_FACTIONS 里那份中立声望,名牌变黄,右键出对话。
镜像那半也一起做了(部落通用 66 共 514 只、辛特兰雷文德巨魔 893 共 35 只)。 奥特兰克山羊(模板 1274 / 1275)没有对应的声望阵营,换成模板 7 —— 同阵营里对谁都 中立的那一个,客户端光看掩码就是中立,不需要声望。
改完重启 mangosd(creature_template 是启动时载入的)。验证脚本的结论是:
两个方向都是 0 只「一边友好、另一边硬敌对且没有声望条」的生物。
character_reputation.standing 存的是相对 base 的增量,所以老角色的
standing = 0 会跟着新 base 一起变成中立,不用动。要动的只有存下来的 flags:
ReputationMgr::LoadFromDB() 会拿 DB 里的 AT_WAR 位重新 SetAtWar(true),而
Player::GetReactionTo() 一看到 IsAtWar() 就直接返回 REP_HOSTILE,声望等级
根本不看。
这一步以前是手敲的,2026-09-07 补进了 30-cross-faction.sql 的第 4 段,
所以现在跟着数据库容器启动自动执行,不会再漏:
UPDATE characters.character_reputation SET flags = 0x01
WHERE faction IN (72,47,69,54,76,81,530,68,471,589,630) AND (flags & 0x02);
UPDATE characters.character_reputation SET flags = flags & ~0x02
WHERE faction IN (469,67) AND (flags & 0x02);漏掉它的症状是「DBC 补丁明明打了,暴风城卫兵还是砍我,联盟任务也接不了」——
两个症状同一个根:敌对状态下 GetNPCIfCanInteractWith() 连对话都不给,
quest_template.RequiredRaces 清成 0 也没机会生效。
flags & 0x02 这个条件让它只碰真正带开战位的行,本方阵营那几行不动,重复执行是
空操作。副作用是手动宣的战会在下次数据库容器启动时被停战回去,声望面板里再点一次
即可。
ReputationMgr 是登录那一刻从 DB 读进
内存的,下线时 SaveToDB() 会把那份内存状态原样写回来 —— 也就是把刚改好的
flags 又覆盖成开战。2026-09-07 就踩过一次:SQL 跑完看着是对的,角色一下线
14 行全变回去了。所以顺序是先退出客户端,再跑 SQL,然后登录;
select count(*) from characters where online = 1 是 0 才算安全。
新建的角色不需要这一步,创建时直接读新 DBC。
ObjectMgr::GetNearestTaxiNode() 按 TaxiNodesEntry::MountCreatureID[team] 过滤
(槽 0 = 部落,槽 1 = 联盟),联盟航点的部落槽是 0,所以部落角色站在暴风城的
狮鹫管理员面前解析不出"当前航点",SendTaxiMenu() 直接 return —— 不是"没有航线",
是飞行地图根本不弹。30-cross-faction.sql 第 8 段把只有一边的航点补成两边都有,
62 行(taxi_nodes 每个航点每个 build 各一行);坐骑按大陆取(东部王国狮鹫 541 /
卡利姆多角鹰兽 3837,部落两边都是双足飞龙 2224),飞起来的模型跟当地风格一致。
同名的孪生航点不补:藏宝海湾、加基森、永望镇、月光林地、圣光之愿、
塞纳里奥要塞、瑟银哨塔这七处 Blizzard 本来就是一边一个航点,各自连着自己阵营的
航线网。两个都放开的话 GetNearestTaxiNode() 取"最近"会在两个几乎重合的航点之间
乱挑,部落玩家在藏宝海湾可能被解析成联盟那个航点,从藏宝海湾飞格罗姆高反而会坏掉。
认孪生用的是名字而不只是距离 —— 暴风城航点旁边 204 码就有一个
Generic, World target 的僵尸行,只看距离会把最该补的那个跳过去。
TaxiPath.dbc 没动,两张航线网仍然是分开的。所以效果是走到对方任意一个航点,
那整张网就整个开给你,不是从奥格瑞玛直飞暴风城。
两处更正。其一:这里最早写的是"客户端的
TaxiNodes.dbc得跟着改(要打patch-B.MPQ)"。MountCreatureID是服务端用来过滤航点和挑坐骑模型的,客户端 画飞行地图靠的是服务端发的SMSG_SHOWTAXINODES里那份 taximask,按这个推断 单边就够——但没有实测过。要是飞行地图能弹出来、对方的航点却不画,那就说明 客户端也在过滤,那时候才需要patch-B.MPQ。其二:接着又写成改
TaxiNodes.dbc,还是错的 —— 航点在世界库的taxi_nodes表里(见本节开头的更正)。顺带:
AllFlightPaths = 1替代不了这一段。它只是把 taximask 全点亮,GetNearestTaxiNode()那道阵营过滤照样在,对方的飞行管理员还是不理你。
# 阵营和航点都在世界库里,下面那个 SQL 文件就够了;两个 DBC 脚本可跑可不跑
bin/patch-faction-dbc.py --revert
bin/patch-taxinodes-dbc.py --revert
rm vmangos-deploy/storage/database/custom-sql/30-cross-faction.sql
docker compose exec -T database sh -c 'mariadb -umangos -pmangos mangos' \
< vmangos-deploy/storage/database/custom-sql/revert-cross-faction.sql.example
bin/02-extract-server-data.sh
会被原始 DBC 覆盖,之后 bin/patch-faction-dbc.py 和 bin/patch-taxinodes-dbc.py
各要再跑一次。原始文件留在同目录的 Faction.dbc.orig / TaxiNodes.dbc.orig。
不过这两个补丁对服务端本来就没作用,重新提取不会把跨阵营弄坏 —— 真正的改动在
世界库里,30-cross-faction.sql 每次数据库容器启动都会重新执行一遍。
-
maps/vmaps/dbc/mmaps全部提取完毕(mmaps 2002 个 mmtile / 1.9 GB)。 重跑提取见bin/02a-*/bin/02b-*,跑完用bin/04-mmaps-then-restart.sh加载。 提取是在本机做的:extracted-data已 rsync 到线上(10716 个文件逐一比对大小一致)。 以后重跑提取,跑完要再同步一次:rsync -a --delete \ vmangos-deploy/storage/mangosd/extracted-data/ \ "$SERVER_SSH_USER@$SERVER_HOST":/opt/vmangos/storage/mangosd/extracted-data/ ssh "$SERVER_SSH_USER@$SERVER_HOST" 'cd /opt/vmangos && docker compose restart mangosd'
macOS 自带的是 openrsync,不认
--info=progress2/--no-inc-recursive,别加。 -
改了
config/mangosd.conf之后要推到线上再重启(线上是单文件 bind mount, 同样不能在远程sed -i——换 inode 会断挂载,必须整文件覆盖):scp vmangos-deploy/config/mangosd.conf "$SERVER_SSH_USER@$SERVER_HOST":/opt/vmangos/config/ ssh "$SERVER_SSH_USER@$SERVER_HOST" 'cd /opt/vmangos && docker compose restart mangosd'
config/realmd.conf 里设了 StrictVersionCheck = 0(默认是 1)。
开启该项时 realmd 会校验客户端二进制的 integrity hash(realmd.allowed_clients 里
1.12.1 enUS Win x86 那条是 95EDB27C7823B363CBDDAB56A392E7CB73FCCA20),任何非逐字节
一致的二进制都过不了。我们跑的是 vanilla-tweaks 产出的 WoW_tweaked.exe,按这个机制
就属于"改过的客户端",所以这一项保持关闭。
症状是 realmd 日志 tried to login with modified client!、客户端显示
Login failed: Version mismatch。SRP6 密码校验本身不受影响——失败只发生在版本证明
这一步,所以很容易误判成账号或密码问题,用 bin/auth-check.py 可以把两者分开。
.claude/skills/vanilla-wow-local/ 是一个 submodule,指向
vvenv/wow-vanilla-server-mac ——
同一套流程打包成的 Claude Code Skill,单独一个仓库是为了能在别的项目里也用上。
克隆本仓库时带上它:
git clone --recurse-submodules https://github.com/vvenv/wow.git
# 已经克隆过了:
git submodule update --init改 skill 请到那个仓库里改,这边只跟一个指针 —— 两处各改一份必然漂移。
本仓库不包含也不分发任何暴雪的游戏资源、二进制或代码。所有 MPQ、DBC、美术资源
都需要你自行提供一份合法取得的 1.12.1 客户端;文档只说明如何验证与提取。
client/、client-zhCN/、vmangos-deploy/、addons/、downloads/ 全部在 .gitignore 里。
addons/ 装的是第三方插件,各有各的来源和许可,由 bin/install-addons.sh 按需拉取,
不随本仓库分发。
MIT,见 LICENSE。上游组件(VMaNGOS、vmangos-deploy、WoWSilicon、DXVK、 各插件)各自遵循其原本的许可。