파이어앰블램 좋아하시는 분 ?
파앰에선 ( 그 외 다른 게임 다수) 난수 시스템으로 특정 행동을 하면 다음 행동을 강제화 하는데, 난수 시스템을 구현하기 위해 필요한 결정론적 시드, 시드 개념에 대해 알아보자.
일단 컴퓨터가 난수를 만드는 방식에 대해 먼저 알아보자.
크게 의사난수(PRNG) 와 진짜 난수(TRNG)로 나뉜다.
진짜 난수는 자연계의 물리법칙 처럼 정말 예측 못하는 난수고, 오늘은 컴퓨터가 계산하는 의사난수에 대해 알아볼 것 이다.
사실 CPU는 정해진 명령을 수행하는 기계라서 스스로 "무작위"를 만들 수 가 없다.
그래서 내부 상태를 "수식"으로 변형하면서 결과가 무작위로 보이는 숫자 열을 뽑아낸다.
시드(초기값) → 상태 → [수식 적용] → 다음 상태 → 숫자 출력 → [수식 적용] → ...
상태가 수식으로만 결정되니까, 시드가 같으면 결과가 항상 같다.
C# 에서는 system.Random(seed) 형식으로 seed 를 넣어서 항상 같은 값을 뽑아낼 수 있다. 이것이 "결정론적 시드" 이다.
savefile 마스터 시드 + SplitMix64 믹싱으로 시드를 만든다.
// RandomManager.cs:42
var state = (ulong)_saveSeed; // ← SaveFile 고유 시드 (hashcode 아님)
SplitMix64.Next(ref state);
state ^= (ulong)(long)tag; // ← 태그를 XOR로 섞음
SplitMix64.Next(ref state);
foreach (var d in discriminators) // ← 카운터 등을 섞음
{
state ^= (ulong)d;
SplitMix64.Next(ref state);
}
var s0 = SplitMix64.Next(ref state);
var s1 = SplitMix64.Next(ref state);
return new Xoroshiro128PlusPlus(s0, s1);
여기서 시드의 재료는 3개
중간에 xor로 시드를 섞는 이유는 다음과 같다.
(xor 은 두 비트를 비교해서 다르면 1, 같으면 0을 내는 연산이다.)
되돌릴 수 있음 (자기 역원)
같은 값을 2번 XOR하면 원래대로 돌아온다.
비트가 골고루 섞인다.
한쪽 값이 무작위라면 xor 의 각 비트가 0일 확률과 1일 확률이 정확히 반반이다.
모든 비트 자리에 영향
+ 는 자리 올림(carry) 때문에 낮은 자리가 높은 자리에 영향을 주지만 방향이 한쪽이다. 각 비트가 독립적으로 뒤집혀서, 상위 하위 비트 어디든 균등하게 반영된다.
앞서 말한 시드 생성에서 기존에 섞여 있던 상태에서 새 상태를 주입시킬때 xor 를 사용하면
2.균등하게 섞인다.
xor 은 각 비트가 반반 확률로 뒤집혀서 치우침이 없다.
시드는 골고루 퍼져야 좋은 난수값이 나온다.
SplitMix64 가 그 차이를 온전히 비트로 퍼트린다. Sebastiano Vigna가 공개한(원 설계는 Steele, Lea, Flood의 Java 8 SplittableRandom 논문) 64비트 PRNG
public struct SplitMix64
{
ulong _state;
public SplitMix64(ulong seed) => _state = seed;
public ulong Next()
{
unchecked
{
ulong z = (_state += 0x9E3779B97F4A7C15UL);
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
return z ^ (z >> 31);
}
}
}
이름 자체도 나타났듯이
xor, rotate, shift, rotate 이 4개로 상태 갱신을 한다
밀려나서 버려질 비트를 다시 반대편 끝으로 되돌려 넣는 시프트
private static ulong RotateLeft(ulong x, int k) => (x << k) | (x >> (64 - k));
이해하기 쉽게 8비트를 예시로 들고
x = 1011_0010, k = 3 라고 가정해보자
1단계)
x 를 왼쪽으로 3(k) 번 밀기
← 밀어냄
[101] 1 0 0 1 0 . . . ← 왼쪽으로 삐져나간 101은 버려짐
= 1 0 0 1 0 0 0 0 ← 빈 오른쪽은 0으로 채워짐
2단계)
x >> (8-3) = x >> 5 : 그 사라진 놈을 따로 건져내기
1 0 1 1 0 0 1 0
→ 오른쪽으로 5칸
0 0 0 0 0 1 0 1
3단계)
or 로 합치기
1 0 0 1 0 0 0 0 (<< 3 결과)
| 0 0 0 0 0 1 0 1 (>> 5 결과)
─────────────────
1 0 0 1 0 1 0 1
여담)
C#에서는using System.Numerics; ulong r = BitOperations.RotateLeft(x, 24);이걸로 rotate 하는게 이미 있단다 wow
이렇게 rotate 를 해주는 이유는 단순한 시프트 연산은 정보를 한 방향으로 흘리고 밀려난 비트는 버려진다. 이런 생성기는 "zeroland 탈출" 이 느리다.
상태에 우연히 0이 많이 몰린 구간에 들어가면 0이 거의 흩어지지 않은 출력이 연달아 나오게 된다.
rotate 는 버리는 비트 없이 64비트 전부를 다른 자리로 옮기기에 위와 같은 문제가 발생하지 않는다.
그럼 다시 돌아가서
var s0 = _s0;
var s1 = _s1;
var result = RotateLeft(s0 + s1, 17) + s0; // ① 출력 계산
s1 ^= s0; // ② 상태 갱신
_s0 = RotateLeft(s0, 49) ^ s1 ^ (s1 << 21);
_s1 = RotateLeft(s1, 28);
return result;
++ xoroshrio 는 상태가 전부 0이면 0만 뱉는 치명적인 약점이 존재한다. 방금 말한 동작들로는 0에서 벗어날 수 가 없기 때문.
위 코드에는 이를 방어하는 방어코드가 존재한다.
if (s0 == 0 && s1 == 0) s0 = 1;
지금 보고 있는 결정론적 난수 생성기에서는 앞서 말한 splitmix64(시드 준비) 와 xoroshrio(난수 스트림 생성) 2가지의 알고리즘을 적절하게 사용하고 있다.
같은 savefile 시드 + 같은 tag + 같은 카운터면 언제 어디서 돌려도 똑같은 뽑기 결과가 나오게 된다!