从零读懂 eFootball 的 Player.bin:一份逆向学习笔记

最近有人问我 Player.bin 该怎么解,问题一口气列了十几个。我把回答整理成一份学习笔记:文件在哪、记录怎么切、ID 怎么用、位级字段怎么读,以及最重要的一件事——怎么证明一个偏移真的是对的。

· 22 min read

六月我写过一篇《Player.bin 逆向手记》,讲的是四次翻车。那之后游戏换了一次数据格式,旧的记录布局整个作废,我几乎是从头又解了一遍。

最近有人来问:想用 Player.bin 做一个 eFootball 数据站,该从哪下手?问题问得很好,一口气列了十几个:文件在哪、记录定长还是变长、ID 存在哪、有没有确认过的偏移、差分法靠不靠谱……我索性把回答整理成这篇学习笔记。

它不是一张”照抄就能用”的偏移表。偏移表会过期,Konami 每次更新都可能动它。我更想讲清楚的是怎么找、怎么验,这部分不会过期。

9 月 26 日更新:发出去第二天,我拿官方库和补丁库把文中每个数字重新核对了一遍,改了几处(特殊卡的数量、ID 怎么分族、卡包名、空表数量、两个新技能的验证方式),都记在第十节。

先说边界。 这是我在自己的正版安装上做的独立格式分析,和 Konami 没有任何关系。文中不含任何游戏文件、数据导出或密钥,只讲格式和方法。只针对离线单机:联网模式由服务器说了算,这里的东西碰不到,我也没兴趣碰。修改数据库大概率违反用户协议,动手前先备份,后果自负。


一、先搞清楚它是什么,不是什么

动手之前,最该先回答的问题是:这份数据到底能回答什么问题?

Player.bin 是游戏自带的离线数据库,存的是授权俱乐部和国家队的球员,外加一部分特殊卡,离线比赛(比如 Trial Match)读的就是它。

它不是账号数据。梦幻球队那一侧的东西全在 Konami 的服务器上,本地一份都没有:

本地文件(离线生效)服务器(账号数据)
能力值、技能、位置适性、状态拥有哪些卡、卡的等级、训练点
阵容、背号、队长和主罚每张卡装了什么增能
阵型、教练的名字和国籍GP、金币、自己的阵容和教练

本地倒是有一张 PlayerBooster.bin,但它只是增能的定义表,不记录谁装了哪个。

它也不是完整的卡库。我看过的官方版本里,Player.bin 大约 23,500 条记录,特殊卡只有 700 张左右(我对比过的一个社区补丁库有约 13,600 张)。Konami 并没有把梦幻球队里所有的卡都放进离线库,所以想做卡牌目录,PESDB 这类网站仍然是绕不开的数据源。

不过库里有的那些特殊卡,信息是全的:每张都能查到它出自哪个卡包(第九节),卡种(Big Time、Show Time……)直接编码在卡的 ID 里(第四节)。

Player.bin 真正值钱的,是网站上不容易看到的东西:精确的存储值、阵容和背号、青训俱乐部和租借信息、第二国籍、球队阵型、外观数据。


二、文件在哪、怎么打开

我用的是 eFootball 2027,Steam 版(PC),所有结论都是 2026 年 8 到 9 月间在几次周更数据库上做出来的。主机版和手机版我完全没碰过。

<Steam 库>\steamapps\common\eFootball\cpk\dt200_console_all.cpk
  └─ common/etc/pesdb/Player.bin      (这个目录里一共 48 张表)

它藏在两层包装里:

  1. dt200_console_all.cpk 是 CRI 的 CPK 归档,任何 CPK 解包工具都能列出和取出里面的文件。
  2. 里面每一张 .bin 都是一个 WESYS 容器:16 字节头,后面是 payload。从游戏 5.0.0 版起,payload 是先 zlib 压缩、再叠一层 XOR 流加密,这也是老的 PES 时代工具全部失效的原因。加密常量随版本轮换,这篇不讲解密细节,只讲拿到明文之后的事。

头部长这样:

偏移类型含义
0x00–0x023 × u8标志位,正常表是 FF 22 83
0x03–0x07char[5]"WESYS"
0x08–0x0Bu32 LE压缩后大小(= 文件大小 − 16)
0x0C–0x0Fu32 LE解压后大小

注意 magic 是从第 3 字节开始的,不是第 0 字节,拿文件开头去比永远比不上。

这一步最值得学的是:永远先做能自证的检查。 解出来的 payload 开头必须是合法的 zlib 头(78 01 或 78 DA),解压后的长度必须和头里写的完全一致。两条同时成立才算解对,只满足一条都不算。

几个坑:

  • 有几张表(官方版本里是 9 张,补丁版是 7 张)只有一个 16 字节的头,两个大小都是 0。它们是合法的空表,不是坏文件,要在调用 zlib 之前单独处理。
  • 游戏运行时会锁住 cpk,读之前先关游戏,或者先拷一份出来。
  • 最贵的一个坑:dt870_console_win.cpk 里有一整套同名的表(Player.bin、Team.bin……),但那是旧格式(392 字节一条),当前游戏根本不打开它。拿 400 字节步长去遍历它,会得到一堆看起来非常像真的垃圾数据。所以任何遍历之前先断言:文件大小能被 400 整除。

三、记录结构:400 字节,一刀切

Player.bin 是定长记录:每条 400 字节,没有文件头、没有索引、没有填充。记录数 = 文件大小 ÷ 400。

名字就存在记录内部的定长槽位里,所以名字长短不一也不影响步长。

字节类型含义
0–3u32青训俱乐部 ID
4–7u32租借母队 ID(没租借为 0)
8–15u64球员 ID(PID),全表按它升序
16–19u32卡片所属俱乐部(历史归属)
20–30价格类数据,离线用不上 *
31–87位压缩的属性区(见第五节)
88–392名字区:5 × 61 字节 UTF-8
393–399恒为 0

* 例外:bit 223 落在这一段,它是一个真正的技能位。另外,16–19 是卡片的历史归属,不是当前阵容;当前阵容要看 PlayerAssignment.bin(见第九节)。

字节 0–7 我一开始认错了,当成”内部 PID”,还写进了论坛帖。后来发现这 8 个字节在 4 万多条记录里只有大约 1,500 种取值,一个索引不可能重复到这个程度。它其实是两个 u32 俱乐部 ID。按第一个值分组,出来的是教科书式的青训营:108 是巴萨(梅西、佩德罗、布斯克茨),116 是阿贾克斯,193 是葡萄牙体育(C 罗)。第二个值非零,当且仅当租借到期日非零:1,874 对 1,874,零例外。

学习点:看一个字段的取值分布,往往比看某一个取值更能说明它是什么。“只有 1,500 种取值”本身就是证据。


四、球员 ID:存在哪、怎么用

ID 就直接存在字节 8–15,u64 小端序,没有另外的索引表。整张表按它升序排列,游戏靠二分查找找人,所以如果你往里写记录,一定要保持有序,否则查找会静默失败。其他表(阵容、外观)也都用同一个 ID 指向球员。

ID 本身还有结构:

  • 低 24 位是”人”,高位是”卡”。 同一个球员的所有卡,低 24 位都相同:C 罗所有的卡都是 4522,内马尔都是 40352。脸模也按这个”人 ID”查。
  • ID 分成两族,中间有一段空档:2²⁸ 以下是普通卡,包括基础卡本身(24 位以内),以及一批 25–27 位的其他普通版本(官方库里约 2,950 张);2³² 以上是特殊卡,大部分在 45 位以上,但也有一些落在 32–44 位(官方库里 221 张)。
  • 特殊卡的卡种就编码在 ID 里:(ID >> 40) & 0xFF 能分出 Big Time、Show Time、周最佳 / 月最佳 / 赛季最佳、Selection、传奇、俱乐部赛季卡、活动卡等。
  • 这和 PESDB 上用的 ID 是同一套。我的 PESDB 抓取结果按完整 ID 直接连表,匹配上了四万多人。
  • 按人去重请用低 24 位,别用名字:库里有重名的不同球员(比如有两个不同的”卡卡”)。

取一条记录的原始 400 字节,最简单的是二分查找:

import struct
REC = 400

def find_record(data, pid):
    lo, hi = 0, len(data) // REC
    while lo < hi:
        mid = (lo + hi) // 2
        (p,) = struct.unpack_from("<Q", data, mid * REC + 8)
        if p < pid:
            lo = mid + 1
        elif p > pid:
            hi = mid
        else:
            return data[mid * REC:(mid + 1) * REC]
    return None

别用”在文件里搜 ID 字节”的方式找记录。 小 ID 大部分字节是 0,会在几百个随机位置命中。实在要搜,只接受 (offset - 8) % 400 == 0 的结果。


五、位级字段:先把”怎么读”读对

属性区是位压缩的,字段不按字节对齐,好几个还跨字节。下面所有偏移都是从记录开头算起的 bit 偏移,位序 LSB-first:第 N 位在第 N // 8 字节的第 N % 8 位,多位字段的第 i 位来自第 N + i 位。

def read_bits(rec, bit, width):
    v = 0
    for i in range(width):
        p = bit + i
        v |= ((rec[p >> 3] >> (p & 7)) & 1) << i
    return v

26 项能力,每项 6 位,显示值 = 存储值 + 40(但请先看第六节的刻度问题):

能力bit
速度 Speed434
加速 Acceleration486
射门 Finishing530
盘带 Dribbling492
短传 Low Pass524
高传 Lofted Pass448
防守意识 Def. Awareness390
身体对抗 Physical Contact518
进攻意识 Off. Awareness498
控球 Ball Control396
紧密控球 Tight Possession550
头球 Heading402
定位球 Set Piece Taking368
弧线 Curl428
抢断 Tackling454
侵略性 Aggression512
防守参与 Def. Engagement544
踢球力量 Kicking Power384
弹跳 Jumping408
平衡 Balance504
体力 Stamina480
GK 扑救意识472
GK 接球416
GK 反应460
GK 倒地扑救466
GK 控制范围422

其他字段:

字段bit宽度换算
身高2488值 + 100 = cm
体重2807值 + 30 = kg
年龄5366值 + 10
注册位置5564编码见下
国籍3299存 国家 ID ÷ 2
第二国籍3209同上,0 = 没有
惯用脚65410 右 / 1 左

位置编码:0 GK · 1 CB · 2 LB · 3 RB · 4 DMF · 5 CMF · 6 LMF · 7 RMF · 8 AMF · 9 LWF · 10 RWF · 11 SS · 12 CF。

体重一定要读 7 位:只读 6 位的话,93 kg 以上的球员全部回绕成一个很小的数。

另外还有 67 个技能位(其中 2 个是 9 月新出的)、两个球风字段(进攻 / 防守)、12 个位置适性、状态和伤病抗性,完整位图在我的论坛帖里,这里不重复。只提一个最容易错的:位置适性看起来是 576–599 一整块,其实不是。LB 在 318,CMF 在 510,都在块外面。

学习点:给自己的读取器找一条不变量。我用的是:“球员自己的注册位置,在对应的适性槽位里永远读到 2(A 级)“。只要有一条记录不满足,就说明偏移或位序错了。


六、最容易掉的坑:显示刻度

这是我栽得最久的一个。

“存储值 + 40”只对特殊卡(ID ≥ 2³²)成立。对于普通卡(ID < 2²⁸),PESDB 显示的是:

max(40, round((存储值 + 40) × 24 / 25))

加上这一条规则,我抓的 42,369 名球员、110 万个能力格子里,只有 1 格对不上。不加的话,大约三分之二的球员会整体高出 4–5 分。后来我又拿当前的官方库和补丁库重新验了一遍,包括原来那份库里没有的 32–44 位特殊卡,规则依然成立。

有意思的是,我之前在论坛上写的是”能力值和 PESDB 的相关系数 0.95–0.997,差距来自 PESDB 和游戏文件不是同一个快照”。听起来很合理,但错了:差距的大头就是这个刻度。按 ID 分成两族分别看,差距当场消失。

要说清楚我确认了什么:这条规则和 PESDB 以及总评公式都对得上。至于游戏自己在普通卡的卡面上画的是哪一种刻度,我还没有确认。


七、总评不在文件里

Player.bin 里没有总评字段。它是游戏实时算出来的,输入是能力值、注册位置,以及一个我完全没想到的量:身高。

总评 = max(40, round( Σ 权重ᵢ × 能力ᵢ + h × 身高 + c ))

权重、h、c 都按位置不同。我用 PESDB 的数据按位置拟合,在没参与拟合的球员上:94.2% 完全命中,100% 误差在 ±1 以内。剩下的误差全是 1,是取整造成的。

身高的系数排序本身就很说明问题:门将每厘米 +0.185,中卫 +0.140,中锋 +0.099……边前卫几乎为 0。这正是懂球的人会猜的顺序,所以我相信它是真的,不是统计巧合。


八、核心:怎么证明一个偏移是对的

有人问:“差分法对常见取值可靠吗?还是罕见值、极端值更适合当锚点?”

我的答案是:**常见值完全可以,甚至更好,前提是你在整个语料库上打分,而不是盯着几个球员看。**罕见值和极端值有它们自己的用处。下面是我实际用下来有效的做法。

a)先有基准数据。 我的基准是对 PESDB 的一次全量抓取(42,369 人,抓取过程见这篇),用完整 ID 连到 Player.bin 上。没有基准数据,后面所有方法都无从谈起。

b)扫,不要猜。 对于技能这种 1 位的标志,拿记录里每一个 bit 位置去和已知的那一列比,找纯度 100% 的:零假阳性、零假阴性。对于数值,试每一个起始位和宽度,算相关。四万人的样本下,正确答案通常遥遥领先,错误的候选根本活不下来。最初的 65 个技能位我就是这么逐个定下来的;9 月新出的两个 PESDB 还没收录,是靠对比前后两版数据库找到的(见第三节的做法)。

c)罕见值会从两个方向骗你。 样本只有几个人的时候,随机的 bit 也能相关得很漂亮。有三个特殊卡专属技能,全库分别只有 1、9、22 张卡持有,它们曾经给我制造过一个假相关;而另一次,我又把它们当成噪声扔掉了,其实它们是真的。

d)给字段贴标签,要找一条”取值各不相同”的记录。 几个字段取值差不多的时候,一个球员可以”证实”错误的标签。给体型字段贴标签时,我第一个参照球员身上重复值太多,结果有 96 种标签排列全部完美吻合。换成一个 11 项里 10 项互不相同的球员,96 种一下子塌缩成 1 种。

要钉死一组标签,找的不是”验得多”,而是”信息量最大”的那一条记录。

e)极端值最适合做写入测试。 最强的验证,是把值写进文件,然后进游戏看。我把一整队球员的能力全部写到 6 位的上限(存储值 63,也就是 103,超过常规上限 99),游戏界面里全部显示 99:说明游戏确实读了文件,只是界面封顶。把体型数值推到极限,3D 模型肉眼可见地变了。这证明的是游戏实际怎么做,而不只是”和某个网站对得上”。

f)固定住混杂因素。 什么都和球员好坏相关。我曾经有一个”身价”候选字段,固定总评之后看它和年龄的关系:年龄越大它越高,和身价的规律正好相反,这个假设当场作废。

g)怪”数据漂移”之前,先看剩余误差有没有规律。 第六节的刻度问题就是例子:我把一个有结构的误差当成了随机漂移。

h)别假设字段是连续的。 位置适性就是反例。而且一个字段错一位,看起来可能 99% 都对:我的国籍字段就这样错过一次(见第十节)。


九、做一个完整的数据站,还需要哪些表

都在同一个 pesdb 目录里:

  • Player.bin 本身:名字(5 个槽位:全名、中文名、球衣名、罗马音名、球衣名副本)、位置、技能、球风、国籍 ID。
  • PlayerAssignment.bin:阵容。24 字节一行:球员 ID、球队 ID、背号(存的是从 0 开始)、队内顺序、6 位角色掩码(队长、点球、四个定位球)。
  • Team.bin:1,600 字节一队:球队 ID、教练 ID、队名。
  • Coach.bin:176 字节一个教练:ID、国籍(和球员共用一张国籍表)、日文 / 罗马音 / 中文名。
  • Tactics.bin + TacticsFormation.bin:每支队的阵型,11 个位置,坐标落在固定网格上。
  • PlayerAppearance.bin:64 字节一人,按球员 ID 索引:体型、脸、发型等。
  • PlayerVariationDetail.bin:特殊卡 ID → 卡包名(比如 “Big Time Portugal 23 Jun ‘26”)。库里每张特殊卡都有,官方版本里一共 425 个不同的卡包名。注意名字里的日期不一定是发布日期:Big Time 带的是它纪念的那场比赛的日期,库里就有一张 “Big Time 1 Jul ‘90”。
  • Playstyle.bin / PlayerSkill.bin:ID → 日文名称的文本表。

几个值得注意的细节:

  • PlayerAssignment 的球队 ID 是 u32,不是 u16。 按 u16 读,前面大部分都对,然后 24 支非洲国家队(ID ≥ 81,952)会悄悄被截断成垃圾。
  • 同一个球员可以合法地出现在多支队里(俱乐部 + 国家队)。
  • 这一代的教练不带任何战术数据:CoachTactics.bin 和 CoachTacticsFormation.bin 都是空表。战术属于球队,不属于教练,这和 PES 时代不一样。
  • 外观表里体型的 11 项我在游戏里读写双向确认过;其余大部分字段来自一张较早的社区图,我没有逐项验证。

十、我纠正过的错误

公开写出来的东西也会错。把错误标出来,比悄悄改掉更有用:别人可能已经照着旧版本写了代码。

  1. 原来说字节 0–7 是”内部 PID”。实际上是两个 u32 俱乐部 ID(青训 / 租借母队),见第三节。
  2. 原来说国籍在 bit 328,宽 10 位。实际上是 bit 329,宽 9 位,存 ID ÷ 2。旧读法把第二国籍的最高位也读了进来,凭空多出一批”奇数国籍 ID”,其实是有第二国籍的球员。
  3. 原来说年龄宽 1 位。实际上是 6 位,值 + 10(笔误)。
  4. 原来说存储值 63 在游戏里显示 103。实际上文件里存得下 103,但游戏界面显示 99。
  5. 原来说能力值的差距来自数据快照不同。实际上大头是第六节的刻度规则。
  6. 原来说特殊卡没有真脸,换成特殊卡就变默认脸。被游戏实测推翻:脸按低 24 位的”人 ID”查,特殊卡一样有真脸。

9 月 26 日,这篇文章自己也改了几处(发布时写错的):

  1. 原来说官方库里特殊卡有”几千张”。实际上是 700 张左右。多出来的那部分是 25–27 位 ID 的普通卡版本,它们一张卡包名都没有,不是特殊卡。
  2. 原来说特殊卡的 ID 在 45 位以上。实际上是从 2³² 起,官方库里有 221 张落在 32–44 位。
  3. 原来说空表是 7 张。实际上官方版本是 9 张,补丁版是 7 张。
  4. 原来说有 65 个技能位,并且都和 PESDB 逐个对过。实际上现在是 67 个,9 月新出的两个(Snap Strike、Power Tackle)是靠版本对比找到的,还没和 PESDB 对过。

最后一条尤其值得记住:那是我从数据里推出来的结论,推理链看着很完整,进游戏一看就是错的。分析和实测对不上的时候,以实测为准。


十一、自己动手:一份验证清单

读取器写好之后,不要直接跑全库。先挑 20 个尽量不一样的球员:很高的门将、很矮的边锋、有第二国籍的、正在租借的、一张特殊卡和它对应的基础卡……逐个字段和 PESDB 比。这 20 个全对了,再跑全库对比。

同时让这几条不变量在每条记录上都成立:

  • 文件大小 % 400 == 0,否则直接报错
  • 字节 8–15 的 ID 在整个文件里严格递增
  • ID 为 0 的是空占位,跳过
  • 名字槽位能按 UTF-8 解码
  • 注册位置的适性槽位读到 2

写在最后

有人问我能不能直接给一份 Player.bin 样本,或者把工具开源。样本不行:Player.bin 和从它导出的任何数据都是 Konami 的,我不转发,哪怕是一小段。好在 eFootball 在 Steam 上免费,谁想跟着做,装上游戏就有自己的那一份。

工具我暂时也不公开。但我觉得也不需要:这篇加上论坛帖,已经足够写出一个读取器,而自己写出来的读取器,你才真的看得懂、改得动。

如果你照着做出来的结果和我对不上,欢迎告诉我。对不上的结果,比没有回音有意思得多。