Xonotic 逆向分析(一):生命值与移动系统的服务端权威架构

写在前面

这个系列是 Xonotic 的逆向分析合集。Xonotic 是一个开源的竞技 FPS,我选它的原因很简单:给自己定了个小目标,想在游戏里实现锁血、无限子弹、无敌、传送、飞跃、透视这一堆功能,然后结合 AI 来快速实现。

游戏虽然开源,但我刻意不读源码,全部从二进制出发做黑盒逆向。这样练出来的是”程序理解 + 验证”的能力,而不是背源码。等分析完想核对,直接对照开源代码就行,算是给自己留了一份标准答案。

这一篇是合集的第一篇,内容是 2026-09-14 一整天的研究:先是生命值,再是移动,最后顺藤摸瓜把整个游戏的服务端架构摸了个大概。

生命值(Health)

目标

想实现锁血,第一步得搞清楚:血量到底存在哪,谁在算,怎么变化。

搜内存

先在内存里搜 int 型的血量,搜出来一大堆,改哪个游戏里的血量都不动——这些都是显示用的假值。改成 float 搜之后,找到一个带小数的值,整数部分和游戏里显示的生命值完全一致。直接改它,血量真的变了。这才是真值。

通过 float 搜索定位到真实生命值

观察了一下这个地址,是 0x7 开头的堆地址;之前那堆显示值是 0x5 开头。两类值根本不在同一块区域。

找出写血量的代码

对这个地址下写入断点,让游戏里扣血或加血,断下来之后找到了修改这个地址的汇编代码:

修改生命值内存的汇编代码

用 RFA 换算出文件内偏移 0x22E982,到 IDA 里打开看:

IDA 中 0x22E982 处的代码

这一段在一个超大函数里(sub_22A680),函数内部全是”取指令 / 跳表分发”的结构,字符串里还有 SVVM_ExecuteProgram、Entityfield watchpoint 这些关键字。我意识到这不对劲——这不是普通游戏逻辑,而是一个虚拟机解释器。

我的猜想,和 AI 的核对

按我以前搞游戏逆向的经验,我的第一反应是:[rsi + rax * 4] 指向的就是角色生命值,rsi 应该是角色基址加上后续的偏移之类的东西。

我把已知的信息和我的猜想一起交给 AI 做交叉分析,用它来核对我的猜想到底对不对。AI 给出来的结论把我的猜测基本全盘否定了:

  • 0x22E982 不是 Health 专属的代码,而是 QuakeC 服务端虚拟机(SVVM)里一条通用的 32 位 Store 指令。它自己根本不知道写的是不是血量,写谁、写什么值,全是指令的操作数说了算。
  • 游戏里存在三套几乎一模一样的虚拟机:SVVM(服务端,游戏判定都在这)、CLVM(客户端,画面/HUD)、MVM(菜单)。
  • 真正影响游戏判定的血量在服务端 SVVM 的权威状态里,客户端那份主要用于 HUD 显示,属于副本。
  • 这条指令里 rsi 是 VM 写侧数据区的基址(从程序结构偏移 +0x5C368 取出,整个函数调用期间不变),不是角色基址;rax 是目标槽位下标,血量存在第几个槽由它决定;edx 是要写入的值
  • 因为这是一条通用 Store,游戏里几乎所有变量的写入都会从这条指令经过,所以不能全局 Hook / 禁用,必须先识别出血量对应的 index(槽位下标),再针对它做条件过滤。

结论是:这个游戏的生命值不是在本地算的,是在服务端算的。我最初那套”角色基址加偏移”的思路,在这个游戏里不成立。

关于”服务端”要澄清一点

我当时还下过一个结论:既然是服务端计算的,那本地改就没意义了,数据都在云端,往后就没必要研究。这个结论后来被我推翻——单机 / 本地服务器模式下,你跑的”服务端”就是本机自己这个进程,改它完全有效。只有联机打别人的服务器时,权威值才在云端,本地才真的改不动。所以单机环境下,这条路依然值得继续走。

移动(Movement)

血量那边碰了一鼻子灰,那就换个目标:搞清楚移动到底是在服务端算,还是在客户端算。

找高度值

用未知初始值的方式,反复改变游戏里角色的高度,从内存筛出了一堆相关数值,有 0x7 开头的,也有 0x5 开头的。0x7 开头的应该是服务端模块区域,所以我重点观察 0x5 开头那些值是被什么代码改的。下面是找到的人物高度坐标数据:

人物高度坐标数据

顺着这些值找到修改它们的代码:

修改人物高度的汇编代码

同样的办法算出偏移,到 IDA 里打开:

IDA 中移动相关代码

从函数里挖出的人物机制

把整个函数交给 AI 分析,整理出来的结果相当有信息量,我用人话复述一遍:

(1)输入轴。 0x3CAB40、0x3CAB44 是两个被钳到 [-1,1] 的输入轴(前进 / 侧移),越界还会映射成报文里的按键位(0x8 / 0x10 / 0x20 / 0x40)。0x3CABA4 是一张 64 位的按键位图,每一位对应一个按键状态全局(0x12683A0~0x1268490 那一排就是各按键输入),最高能到 0x40000 位。

(2)发给服务端的 usercmd。 在打包循环里字段顺序一目了然:角度(3×float)、前进 / 侧移 / 上移、压缩角度(2×short,×32767 后写入)、按键、冲动值等。0x3CAB20 区的 usercmd 条目是 160 字节一条,存了多条历史。

(3)客户端本地预测。 0x10CED88 起是一个 3×4 变换矩阵(12 个 float),被全程序 50 处引用,属于客户端大状态结构,矩阵第 3 列就是平移向量。我改的”人物高度”其实是客户端预测位置 / 眼睛高度的 Z 分量。程序还会对两个 3D 点逐轴取 min/max 拼成包围盒,然后遍历所有实体(1464 字节 / 个)做扫掠盒相交测试,命中者记录下标,最后做位置平滑插值。

用大白话把整体流程讲清楚就是这样:

1
2
3
4
5
6
玩家输入
-> 客户端:立即预测并计算 XYZ,画面马上动
-> 客户端:同时把 usercmd 打包发给服务器
-> 服务器:收到后重新模拟运动,算出权威 XYZ
-> 服务器:把权威坐标发回客户端
-> 客户端:拿服务端结果做纠偏 / 平滑

说白了,为了体验,客户端先把位置算出来让画面立刻动,服务端再真实地重新模拟一遍,把结果发回来给客户端校正。所以你把客户端高度直接改成一万,画面可能会瞬间飞上天,但服务器收到的是你的输入动作、不是那一万,它自己算出来的位置还是在地面附近。

两类数据的结论

  • 生命值:客户端可能有显示副本,服务端计算权威值。
  • 位置:客户端自己做预测,服务端计算权威值。

两边都是服务端权威(Server Authority),只是移动对延迟敏感,多了一层客户端预测而已。

服务端权威链路

到这里问题就变成了:用户的指令到服务端之后,是靠什么函数算出坐标和速度的。继续沿着这条线索往下挖,得到了一条完整的服务端链条:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sub_1D98B0  服务端收包 / 解析客户端消息(recv clc_ackframe %i,opcode 50)
| usercmd 存入服务端客户端槽
sub_281280 每帧物理驱动器(调 SV_Physics 组)
|
sub_280690 SV_Physics 总调度:按 movetype 字段分发
|
sub_278E80 / sub_28AB20 SV_Physics_ClientEntity
| 1) 写 time 字段
| 2) 调 sub_28B720(引擎壳:跑 QC SV_PlayerPhysics)
| 3) 调 sub_22A680(PlayerPreThink)
| 4) 按 movetype 分发引擎物理
| 5) 调 sub_22A680(PlayerPostThink)
|
sub_286930 VM_SV_walkmove(walkmove 内置)
|
sub_26A800 真正的"带碰撞改位置"底层原语

这里有几个最硬的证据,全部来自二进制本身:

  1. 二进制里直接内嵌了 QC 函数名和缺失报错:QC function PlayerPreThink is missing(0x3045A0)、QC function PlayerPostThink is missing(0x304600)、QC function SV_PlayerPhysics is missing(0x304DC8),以及名字字符串 "PlayerPreThink"/"PlayerPostThink"/"SV_PlayerPhysics"/"StartFrame"(0x31E581 / 0x31E571 / 0x31E633),被 sub_205080(启动时解析 QC 函数的初始化代码,31KB)引用。

  2. SV_Physics_ClientEntity 里跑 QC 的两个调用点被我坐实了:

1
2
3
4
0x278F4E  (*(&xmmword_115BCE0 + 1))(byte_10FF300, dword_115BBF8,
"QC function PlayerPreThink is missing")
0x27904D (*(&xmmword_115BCE0 + 1))(byte_10FF300, dword_115BBF4,
"QC function PlayerPostThink is missing")

xmmword_115BCE0 的第二项,正是第一轮追踪时 SpawnServer 注册进全局表的那个函数指针 —— sub_22A680(SVVM 执行器)。也就是说:服务端每帧就是用我最初发现的这个解释器,去跑玩家移动的 PlayerPreThink / PlayerPostThink。

  1. sub_28B720(16KB 大函数)里有字符串 ../../../sv_user.cQC function SV_PlayerPhysics is missing,被 sub_278E80 和 sub_28F5F0 调用,也通过同一个执行器槽(0x115BCE8)去跑 QC 的 SV_PlayerPhysics,还有 cvar sv_playerphysicsqc 可以开关这条 QC 物理。

  2. VM_SV_walkmove:读 VM 栈参数 → 校验实体 → 用偏航角算 sin/cos → 调 sub_26A800 真正移动实体(带碰撞),把结果 1/0 写回 VM 返回槽。movetogoal(SV_MoveToGoal)等其它原语同理。

所以最核心的问题到此已经有了答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
玩家输入
|
客户端 usercmd
|
CL_SendMove
|
服务端接收
|
SV_PlayerPhysics / PlayerPreThink
|
QuakeC VM
|
walkmove
|
碰撞 / 位移原语
|
权威 Position

结语

这一天把生命值和移动相关的内容基本摸完了。收获不在于”找到了哪个地址”,而在于彻底看清了这个游戏的架构本质:玩法逻辑是用 QuakeC 脚本字节码跑在虚拟机里的,服务端才是权威,客户端只有预测和显示。

这也意味着后面做”锁血”这类功能,只能在服务端动手,客户端是实现不了的——客户端拿到的只是显示副本,本地改了也会被服务端算出来的权威值覆盖回去。要做锁血,要么找到服务端 VM 里血量对应的那个 index,在它写入时做条件处理;要么在服务端那一层物理/逻辑链路里做手脚。相比之下移动类功能(传送、飞跃)因为有客户端预测这一层,本地改会”看起来生效”,但最终权威照样在服务端。具体的,下一篇继续。