跳到正文
zenn · 游戏开发社区· 来源· vi·· 5 小时前AI 评分42

为什么宝可梦的个体值是“31”,努力值是“255”?从内存与汇编解读其机制【内部数据解析】

なぜポケモンの個体値は「31」で努力値は「255」なのか?メモリとアセンブリから仕組みを読み解く【内部データ解析】

正文 · AI 翻译

我忽然产生了兴趣,于是决定去看看内存和汇编。

  • 个体值的最高值(V)为什么是“31”?
  • 努力值单项能力值的上限为什么是“255”?
  • 为什么不是整齐的数字,而是这种不上不下的数字?

其实,在这些数字背后,隐藏着自1996年发售的初代《宝可梦 红·绿》延续而来的Game Boy极致的硬件限制,以及开发团队(Game Freak)令人惊叹的位运算匠人技艺。

这次,我们将实际查看初代《皮卡丘版》的内存(WRAM),并剖析宝可梦能力值决定程序的机制。


1. 前置知识:比特就是“翻倍游戏的开关”

在进入正题之前,先整理一下作为本文全部基础的位宽与最大值的对应关系。

所谓比特(bit),就像电灯的开关。
它只能具有“OFF(0)”或“ON(1)”这两种状态。

在二进制中,当所有比特都为 1(全 1)时的最大值用 2^n - 1 表示。
・4-bit(2^4 - 1) ➔ 15 (第一代的个体值上限)
・5-bit(2^5 - 1) ➔ 31 (现代的个体值上限)
・8-bit(2^8 - 1 / 1 Byte) ➔ 255 (现代的努力值上限)
・16-bit(2^{16} - 1 / 2 Byte) ➔ 65535 (第一代的努力值上限)

我们日常使用的十进制是“1、10、100、1000”这样每进一位就乘以 10,而计算机的二进制则是“1、2、4、8、16、32、64、128……”这样价值每次翻倍的“翻倍游戏”世界。

那么,让我们依次把这些开关“全部设为 ON”并试着相加吧。

  • 将4个开关(4位)全部设为ON
    1 + 2 + 4 + 8 = 15(初代个体值的最大值)
  • 将5个开关(5位)全部设为ON
    15 + 16 = 31(现代个体值的最大值)
  • 将8个开关(8位=1字节)全部设为ON
    31 + 32 + 64 + 128 = 255(现代努力值的最大值)
  • 将16个开关(16位=2字节)全部设为ON
    2^{16} - 1 = 65535(初代努力值的最大值)

这些「15」「31」「255」之类不上不下的数字,正是「将准备好的位(开关)全部设为ON时的理论最大值」本身。


2. 前时代的混沌:初代的努力值「65535」与全能力满值的世界

让我们把历史回溯到Game Boy时代吧。

当时的努力值可以将所有能力值累积到“16位(最大65535)”。


▲ 实际的皮卡丘版内存(0xD13C~0xD145)。努力值为“FF FF(65535)”,所有能力都已达到上限的样子

说到底,为什么努力值需要“16位(65535)”这么多呢?

现代的努力值会像“打倒皮卡丘后速度+2”这样,每次增加1~3点。但初代则完全不同,采用的是“打倒对手的‘种族值’会直接加到自己的努力值上”这一极其豪爽的设定。

例如,打倒一只种族值为“HP:80、攻击:82、防御:78、速度:80、特殊:80”的妙蛙花,自己的各项能力努力值就会一下子飙升约+80。

如果把努力值设计成1字节(8位=最大255),仅仅对战3~4场就会立刻达到上限并溢出。为了承载从1级到100级要持续战斗数千次的RPG成长曲线,2字节(16位=最大65535)这样广阔的容器是不可或缺的。

此外,初代的能力值计算公式中还隐藏着一个数学技巧。

\text{由努力值带来的能力值提升值} = \left\lfloor \frac{\sqrt{\text{努力值}}}{4} \right\rfloor \times \frac{\text{等级}}{100}

它是取所累积努力值的「平方根(根号 \sqrt{} )」来进行计算的。

试着计算一下16位的极限值「65535」的平方根,

\sqrt{65535} \approx 255.998\dots \longrightarrow \mathbf{255}

也就是说,这是一个“为了让击败的敌人的种族值累加,先让其累积到16位(65535),在计算状态时取平方根,从而漂亮地落到8位(255)”的、极其合理且优美的数学设计。

来看看ROM内部的汇编指令

让我们来看看初代宝可梦ROM内部的状态计算例程(CalcStat)的实现。

出处:GitHub pret/pokered(engine/pokemon/stat_calcs.asm)

.statExpLoop
	xor a
	ldh [hMultiplicand], a
	ldh [hMultiplicand+1], a
	inc b               ; ルートの候補値を1増やす
	ld a, b
	cp $ff
	jr z, .statExpDone
	ldh [hMultiplicand+2], a
	ldh [hMultiplier], a
	call Multiply       ; (現在の候補値)^2 を計算
	ld a, [hld]
	ld d, a
	ldh a, [hProduct + 3]
	sub d
	ld a, [hli]
	ld d, a
	ldh a, [hProduct + 2]
	sbc d               ; (候補値)^2 が 努力値 を超えたか判定
	jr c, .statExpLoop  ; 超えるまでループして平方根を算出
.statExpDone

Game Boy的8位CPU(定制Z80)并不存在“平方根指令”。因此,开发团队一边将候选值平方(call Multiply)一边比较,用算法的蛮力来求平方根。


3. 【验证】窥探初代皮卡丘的原始内存——消失的“HP个体值”之谜

那么,个体值又是怎样的呢?
初代的个体值是4个开关的“4比特(0~15的16个阶段)”。

这又是为什么没有采用一个整齐的数字呢?
答案就在于Game Boy极限的内存打包方式。

计算机的基本单位“1字节(8比特)”,从正中间对半分开,就可以分割成“4比特 + 4比特(高4位/低4位)”两个房间。这个4比特的单位被称为“半字节(nibble)”。

在当时初代的宝可梦中,随身携带的一只宝可梦所分配到的数据区域仅仅只有44字节。
如果每一项能力值都各消耗1字节,那么光是个体值就会挤压宝贵的内存。因此开发团队选择了“把两项能力值的个体值紧紧塞进1字节里(半字节打包)”这一方案。

  • 字节1:攻击(4bit) + 防御(4bit) = 1字节 (8bit)
  • 字节2:速度(4bit) + 特殊(4bit) = 1字节 (8bit)

也就是说,所谓“为了在仅仅2字节(16位)的内存槽中尽可能多地塞入状态个体值的最小单位”,正是4位(0〜15)。

实际来看一下内存吧。

关注存储着皮卡丘个体值的地址 0xD146 和 0xD147。

  • 0xD146 : [ D C ]
  • 0xD147 : [ 6 9 ]

十六进制的一位正好对应4位(0〜15)。试着把它分解成4个状态。

  • D (十六进制) = 13 (攻击的个体值)
  • C (十六进制) = 12 (防御的个体值)
  • 6 (十六进制) = 6 (速度的个体值)
  • 9 (十六进制) = 9 (特殊个体值)

……您注意到了吗?
「HP」的个体值,在内存中的任何地方都不存在。

Game Boy 卡带搭载的存档用 SRAM 和工作用 WRAM,只有区区几 KB 的极限世界。要保存 5 项能力的 4 位个体值需要 20 位(2.5 字节),但 0.5 字节(4 位)无论如何都不够。


4. 匠人技艺:初代开发团队是如何决定 HP 个体值的?

那么,HP 的个体值是如何计算的呢?
对初代 ROM 进行反汇编并分析代码后,当时程序员们的匠人技艺便浮现出来。

开发团队将“其他 4 项能力值的最低位(奇数还是偶数)”像拼图一样逐位拼接起来,从而当场炼成了本不存在的 HP 个体值。

  • 攻击个体值 为奇数(最低位为 1)则 \longrightarrow +8 (bit 3)
  • 防御个体值 为奇数(最低位为 1)则 \longrightarrow +4 (bit 2)
  • 速度个体值 为奇数(最低位为 1)则 \longrightarrow +2 (bit 1)
  • 若特殊个体值为奇数(最低位为1),则 \longrightarrow +1(bit 0)

我们来实际计算一下刚才的皮卡丘(DC 69)。

  • 攻击个体值 : 13(奇数 / 二进制 1101)\longrightarrow bit 3 = 1(8点)
  • 防御个体值 : 12(偶数 / 二进制 1100)\longrightarrow bit 2 = 0(0点)
  • 速度个体值 : 6(偶数 / 二进制 0110)\longrightarrow bit 1 = 0(0点)
  • 特殊个体值 : 9(奇数 / 二进制 1001)\longrightarrow bit 0 = 1(1点)

\text{炼成的HP个体值} = 8 + 0 + 0 + 1 = \mathbf{9}

这个HP合成逻辑,我们也来看看ROM内部实际执行的原始指令序列。

出处:GitHub pret/pokered(engine/pokemon/stat_calcs.asm)

push bc
	ld a, [hl]  ; 攻撃個体値のバイトをロード
	swap a
	and $1      ; 最下位ビット(奇数なら1)を抽出
	sla a
	sla a
	sla a       ; 左へ3回ビットシフト(×8点)
	ld b, a
	ld a, [hli] ; 防御個体値のバイトをロード
	and $1      ; 最下位ビットを抽出
	sla a
	sla a       ; 左へ2回ビットシフト(×4点)
	add b
	ld b, a
	ld a, [hl]  ; 素早さ個体値のバイトをロード
	swap a
	and $1
	sla a       ; 左へ1回ビットシフト(×2点)
	add b
	ld b, a
	ld a, [hl]  ; 特殊個体値のバイトをロード
	and $1      ; 最下位ビットを抽出(×1点)
	add b       ; 4つのビットを足し合わせてHP個体値(0〜15)が完成
pop bc

提取各能力值的最低位比特,再用sla a(Shift Left Arithmetic=算术左移)一边移位一边相加——这种匠人技艺就这样原样保留在代码中。


5. 第三世代(GBA)的革命:为什么进化成了「31」和「255」?

2002年,硬件完成了向「Game Boy Advance(GBA)」的世代更替。
CPU从8位进化到32位(ARM7TDMI),宝可梦的内部数据结构也经历了戏剧性的刷新。

在这里,让我们回到本文最大的疑问。
“为什么个体值是‘31’,而努力值是‘255’呢?”

我们将剖析成为决定性因素的、两个优美的工程学理由。

为什么个体值是“31(5位)”呢?──32位寄存器的极限打包

GBA的CPU是“32位”的。也就是说,CPU一次能处理的整数的标准寄存器大小以“32位(4字节)”为单位。

另一方面,第三世代的宝可梦总共有“6项能力值(HP、攻击、防御、特攻、特防、速度)”。

那么,若要“把一个32位整数箱子中均匀塞入6项能力值的个体值”,每项能力能分配多少位呢?

32 \text{ bit} \div 6 \text{ stats} = \mathbf{5.333\dots \text{ bit}}

作为整数能够均等分配的最大比特数,只有“5 比特”。

  • 5 比特 \times 6 项能力 = 30 比特(正好能整齐地放进 32 比特中)
  • 剩余的 2 比特 = 作为特性(Ability)标志或蛋标志被充分利用,毫不浪费

如果把一项能力设为 6 比特(0〜63),总计就会变成 36 比特,从 32 比特的盒子中溢出。反过来,如果保持 4 比特(0〜15)不变,就只会用到 24 比特,空间会剩余。

“在 32 比特 CPU 的箱子里,把 6 项个体值尽量塞到极限的最大公约数”──这正是 5 比特(2^5 - 1 = 31)。

为什么努力值是“255(8位)”?——维持初代“+63”的优化

初代的努力值会为每项能力消耗2字节(16位=65535)。如果算上6项能力,仅努力值就要占用12字节。
于是开发团队将每项能力的努力值压缩为“1字节(8位=最大255)”。

接下来才是Game Freak天才般的数理设计。
让我们看看GBA新的能力值计算公式。

\text{由努力值带来的能力值提升} = \left\lfloor \frac{\text{努力值}}{4} \right\rfloor \times \frac{\text{等级}}{100}

当等级为100时,努力值累积到最大255会怎样呢?

255 \div 4 = 63.75 \longrightarrow \mathbf{+63 \text{(最大提升值)}}

通过初代复杂的平方根计算(\sqrt{65535} \approx 255 \div 4 \approx 63)得到的最大提升值,也完全同样是“+63”。

开发团队“在分毫不差地维持初代‘努力值MAX时实际数值约提升63’这一培养平衡的前提下,完全废除了沉重的平方根例程,替换为仅将1字节的努力值右移2位(\div 4)的超高速处理”。

此外,合计值上还设置了“510”这一限制上限。
这是按 255 \times 2 = 510,也就是“恰好能让2项能力值专精(极振)到上限255的两倍数值”来设计的。


总结:约束催生的位运算艺术

我们不经意间严选、并在对战中使用的“31”和“255”这些数值。

那并不只是游戏平衡的数字,而是“四分之一世纪前,屏幕另一侧的开发者们在数KB的内存空间中绞尽脑汁,把一个个比特当作开关来战斗的那段历史本身”。

了解了这样的内部结构之后再去看游戏,平时游玩时看到的画面,是不是会呈现出截然不同的深度呢?


🎬 想通过视频直观理解的人

本文讲解的内容已使用自制动画制作成视频,并作为影像作品在 YouTube 上发布。

希望进一步加深理解的人,也请务必一并观看这段视频。

如果哪怕一点点激发了你的求知好奇心,也请务必点「Like」和「关注」。

今后我也会继续从底层视角讲解游戏内部数据、bug 的真相等内容。

下一回将为大家带来《为什么异色宝可梦的闪光概率是1/8192?异或(XOR)与边界值 bug 的数学解析》。

来源:zenn · 游戏开发社区 · zenn.dev