닌텐도 DS의 유희왕 월드 챔피언십 2008에는 CPU 듀얼리스트가 78명 있고, 모두 레이팅이 하나씩 있다. 상대 카드에 작은 노란 삼각형과 함께 찍혀 있는 숫자다. 내가 찾은 커뮤니티 표들은 전부 듀얼리스트마다 숫자 하나, 그러니까 새 세이브의 값만 적어 두었다. 이 숫자가 움직인다는 얘기는 어디에도 없었다.
그런데 움직인다. 토너먼트 모드를 다시 하다 보니, 대진표 안의 CPU끼리 듀얼이 끝날 때마다 바뀌고, 나와의 듀얼 뒤에는 그대로고, 토너먼트가 끝난 뒤에도 바뀐 값이 유지됐다. 토너먼트는 나와 CPU 7명, 모두 8명이 나오고, CPU끼리의 듀얼은 건너뛸 수 없고 빨리 감기만 된다. 그러니 보든 안 보든 토너먼트 하나마다 레이팅 변화가 4~6번 일어난다.
그래서 토너먼트를 하면서 그 레이팅을 바로바로 입력하고, CPU마다 오르내리는 걸 차트로 보여주는 WC2008 Rating Lab을 만들었다. 그러고 나니 당연한 질문이 남았다. 듀얼 한 판에 몇 점이 움직이는 걸까?
합계가 딱 맞았다
첫 단서는 듀얼이 아니었다. 앱에 로스터를 세팅하면서 CPU 35명의 현재 레이팅을 기록했다. 새 게임 이후로 꽤 많이 움직여서, -354점부터 +620점까지 제각각이었다. 그런데 다 더하니 40,200. 커뮤니티 표에 나온 시작 레이팅의 합과 정확히 같았다.
이건 세이브를 시작한 뒤 모든 듀얼에서 한쪽이 잃은 만큼 다른 쪽이 정확히 얻었어야 가능한 일이다. 그래서 제로섬은 앱이 쓰는 유일한 규칙이 됐다. 한쪽의 새 레이팅을 입력하면 앱이 나머지 한쪽을 채운다. 나중에 세이브 파일을 읽을 수 있게 된 뒤에 보니, 78명 전원의 합도 시작값 94,800 그대로였다.
듀얼 일곱 판, 곡선 하나
제로섬은 누가 내는지는 알려줘도 얼마를 내는지는 알려주지 않는다. 직접 기록한 듀얼 네 판과 첫 에뮬레이터 실행에서 나온 세 판(에뮬레이터 얘기는 아래에서)으로, 레이팅 차이와 움직인 점수를 짝지은 데이터가 일곱 개 생겼다. 훨씬 약한 CPU를 이기면 조금 움직이고 이변이 나면 많이 움직였다. Elo 냄새가 났다.
N = K / (1 + 10^(gap / S))
gap은 듀얼 전 승자 레이팅에서 패자 레이팅을 뺀 값이다. 체스 Elo는 S = 400을 쓰는데, 여기선 크게 틀린다. 1561인 Manju of the Ten Thousand Hands가 792인 Reaper on the Nightmare를 이겼을 때, gap +769에 23점을 가져갔다. S = 400이면 2점 정도가 나온다.
그래서 K와 S를 격자로 전부 대입해 봤다. K는 150부터 170까지의 정수 전부, S는 900부터 1100까지 10 간격. 내림을 하면 일곱 판을 다 맞히는 조합은 딱 하나였다.
N = floor(160 / (1 + 10^(gap / 1000)))
같은 곡선을 반올림하면 일곱 중 다섯이 틀린다. 일곱 중 두 판은 같은 대진의 같은 듀얼이었다. 그 앞에 있던 내 듀얼에서 입력만 다르게 해서 두 번 돌렸더니 결과가 뒤집혔다. 965인 Kaiser Sea Horse가 1522인 Gravekeeper’s Chief를 이기고 125점을 가져갔고, 다른 실행에선 져서 34점을 내줬다. 곡선은 두 결과를 다 맞혔다.
그래도 점 일곱 개를 지나는 파라미터 두 개짜리 곡선은 가설일 뿐이다. 듀얼이 수천 판은 더 필요했고, 그걸 내가 다 보고 있을 생각은 없었다.
RAM에서 레이팅 찾기
세이브 파일이 먼저였다. 256 KiB짜리 파일에 블록이 네 개 있고, 각 블록은 TDGY 매직 넘버, 버전, 길이, CRC32로 된 헤더 뒤에 LZ10으로 압축된 데이터가 붙은 구조다. 블록은 둘씩 짝을 이루고 게임이 번갈아 가며 덮어쓰니, CRC가 맞는 것 중 가장 새 블록을 읽으면 된다. 압축을 풀면 오프셋 0x396에 레이팅 78개가 게임 내 목록 순서대로 little-endian u16으로 들어 있고, 앱에 기록해 둔 CPU는 전부 값이 맞았다. 앱의 로스터 세팅도 같은 방식으로 세이브에서 레이팅을 읽는다.
레이팅이 바뀌는 걸 실시간으로 보려면 RAM에서 찾아야 했는데, 에뮬레이터를 켜기도 전에 치트 코드 하나가 위치를 알려줬다. 한국판용 Action Replay 코드는 DP를 0x021146B0에 쓰고, DP는 압축을 푼 세이브의 오프셋 0x24에 있다. RAM이 그 데이터를 한 덩어리로 들고 있다면 그 덩어리는 0x0211468C에서 시작하고, 레이팅 표는 0x02114A22에 있어야 한다.
하네스는 melonDS DS libretro 코어를 libretro.py로 Python에서 돌린다. ROM과 세이브는 내 것이고, 내 컴퓨터 밖으로 나가지 않는다. 이 코어는 세이브를 frontend에 맡기기 때문에, 스크립트가 첫 프레임 전에 세이브 바이트를 save RAM에 넣어줘야 하고(안 그러면 타이틀 화면에 NEW GAME이 뜬다), 게임이 저장할 때마다 직접 파일로 써줘야 한다. 타이틀 화면에서 A를 누르고 나니 0x02114A22의 156바이트가 세이브의 표와 정확히 일치했고, 4 MiB 메인 RAM 전체에서 일치하는 곳은 거기 하나뿐이었다.
도중에 함정이 하나 있었다. libretro.py는 코어의 픽셀 포맷과 상관없이 스크린샷을 RGBA로 돌려주는데, 이걸 BGR로 읽으면 노란 레이팅 삼각형이 청록색이 된다.
일부러 지기
토너먼트에는 하네스가 그냥 구경만 할 수 없는 듀얼이 하나 있다. 내 듀얼이다. 정직하게 지려면 CPU가 이길 때까지 매 턴을 넘겨야 한다. 항복은 10턴부터 되기 때문이다. 대신 하네스는 내 LP를 0으로 만들고, 게임은 다음 Standby Phase에서 패배를 선언한다.
문제는 LP가 들어 있는 주소가 내 것만이 아니라는 점이다. 양쪽 모두 화면에 보이는 LP 카운터가 있는데, 왼쪽은 0x022CA200, 오른쪽은 0x022CA204다. CPU끼리의 듀얼에서는 여기에 CPU 두 명의 LP가 들어 있다. 거기서 한쪽을 0으로 만들면 레이팅이 걸린 듀얼의 승패를 내가 정해버리는 셈이다. 내 듀얼인지 CPU끼리의 듀얼인지는 is-CPU 플래그 한 쌍으로 구분한다. CPU면 1, 플레이어면 0이라서, CPU 듀얼에선 1/1, 내 듀얼에선 0/1이나 1/0으로 읽힌다.
PLAYER_SIDE = {(0, 1): 0, (1, 0): 1} # the is-CPU flags of the player's duel
side = PLAYER_SIDE.get((game.u16(LEFT_IS_CPU), game.u16(RIGHT_IS_CPU)))
steady, last_side = side is not None and side == last_side, side
플래그는 다음 듀얼이 시작될 때까지 값을 유지한다. 그래서 내 듀얼 전의 가위바위보 동안에는 직전 CPU 듀얼의 1/1이 그대로 남아 있다. 알고 보니 이건 문제가 안 됐다. 듀얼이 시작될 때 플래그 둘과 LP 둘이 한꺼번에 0으로 떨어지니, 남아 있던 플래그 때문에 CPU의 LP를 건드릴 일은 없다. 그래도 스크립트는 CPU 듀얼이 두 플래그를 한 프레임 차이로 세팅하는 경우에 대비해, 두 번 연속으로 같은 쪽이 나와야 LP를 쓴다.
그다음부터 하네스는 가끔 A를 누르면서 10프레임마다 레이팅 표를 확인한다. 정확히 두 개가 바뀌면 CPU 듀얼 하나가 끝난 거다. 양쪽의 전후 레이팅과 함께, 아직 남아 있는 듀얼 보드를 보고 듀얼이 어떻게 끝났는지도 기록한다. 그 뒤로 기록한 CPU 듀얼 9,600판 가운데 9,337판은 LP로, 254판은 덱 아웃으로, 5판은 Destiny Board로, 3판은 Exodia로, 1판은 Vennominaga로 끝났다. 토너먼트 하나에 35초쯤 걸린다.
에뮬레이터가 지어낸 패턴
레벨 1, 2, 3을 돌아가며 토너먼트 375개를 돌리고 나니 뭔가 이상했다. 레벨 2 CPU 여섯이 레벨 2 토너먼트에 한 번도 나오지 않아서, 레이팅이 한 번도 움직이지 않았다.
첫 번째 가설은 레벨 2에 레이팅 커트라인이 있다는 거였다. 레벨 2 참가자 중 1085 아래는 한 명도 없었고, 빠진 여섯은 750~1050에서 시작한다. 연습용 사본에서 여섯의 레이팅을 1300으로 올리고 레벨 2 토너먼트를 세 번 돌렸더니, Molten Zombie가 세 번 다 나왔다. 그럴듯했다.
5분 뒤에 세운 두 번째 가설은, 참가자가 레이팅 순위의 정해진 자리에서 뽑힌다는 거였다. 레벨 안에서 현재 레이팅으로 순위를 매기면, 같은 자리가 토너먼트 125번 중 122~125번 나왔다. 레벨 1은 1, 2, 3, 5, 14, 18, 24위, 레벨 2는 2, 3, 5, 7, 12, 14, 15위. 레벨 3에는 항상 같은 하위 레벨 게스트 셋이 왔다. 이걸로 여섯이 설명됐다. 아무도 뽑지 않는 순위에 앉아 있었던 거다. 레벨 1의 참가자 풀이 좁아진 것도 설명됐다. 첫 25번에는 서로 다른 CPU가 23명이었는데 마지막 25번에는 16명뿐이었다. 이긴 CPU가 뽑히는 순위로 올라가서 거기 눌러앉았기 때문이다.
세 번째 가설은 게임의 출력이 아니라 코드에서 나왔다. 대진 추첨은 overlay 19에 있고, 규칙은 훨씬 평범했다. 게임은 그 레벨의 해금된 CPU를 레이팅순으로 정렬하고, 상위 12명(레벨 3은 15명) 안에서 무작위로 자리를 100번 바꾼 뒤, 위의 3명을 시드로 뽑는다. 나머지 4명은 레벨의 나머지와 하위 레벨의 해금된 CPU 중에서 균등하게 무작위로 뽑는다. 무작위이긴 한데, 저울이 상위권 쪽으로 살짝 기울어 있다.
그럼 순위는 왜 고정돼 보였을까? 난수는 평범한 ANSI C rand()에서 나온다.
class Rand:
"""The game's rand(): the ANSI C generator, 15 bits out of a u32 state."""
def __call__(self) -> int:
self.state = (self.state * 0x41C64E6D + 0x3039) & 0xFFFFFFFF
return self.state >> 16 & 0x7FFF
상태값은 0x020FCD18에 있고, 하네스가 지나가는 경로에서는 전원을 켠 뒤 추첨할 때까지 1에 멈춰 있다. 새로 부팅할 때마다 토너먼트는 같은 난수를 굴렸고, 같은 난수는 레이팅순으로 정렬된 목록의 같은 위치에 떨어진다. 그게 고정 순위의 정체였다. Molten Zombie를 1300으로 올린 실험은 그냥 그 위치 하나로 옮겨준 것뿐이었다. 토너먼트마다 첫 CPU 듀얼이 반복된 것도 비슷한 이유였다. 새로 부팅하면 첫 듀얼에 들어갈 때 프레임 카운터가 항상 같은 값이었는데, 듀얼의 시드가 바로 그 값에서 나온다. 두 번 이상 나온 첫 듀얼 대진 95개 중 56개는 매번 똑같이 흘러갔다.
실제로 플레이할 때는 멈춘 rand()가 문제 될 일이 훨씬 적다. 게임이 첫 듀얼에서 타이밍 값으로 rand()를 다시 시드하니까 그 뒤의 추첨은 무작위고, 내 실제 레벨 2 대진도 이 규칙에 맞는다. 하지만 하네스는 토너먼트마다 새로 부팅했으니, 하네스가 본 추첨은 전부 첫 추첨이었다.
첫 가설을 세우고 한 시간도 안 돼서, 부팅할 때 rand()에 무작위 시드를 넣고 메뉴에서 0~599프레임을 무작위로 기다리게 고쳤다. 데이터셋은 버리고 새 fork에서 다시 시작했다. 그렇게 세 시간 동안 토너먼트 300개를 돌린 뒤에는 프레임 카운터 시작값도 무작위로 했다. 이제 draw.py가 같은 rand(), 같은 unstable quicksort, 같은 뽑기로 추첨을 Python에서 재현한다. 실험 중에 캡처한 대진 41개와도, 시작 세이브를 남겨 둔 토너먼트 전부와도 일치했다.
결정론적인 에뮬레이터는 측정 도구로는 훌륭해도 표본으로는 형편없다. 같은 fork 지점에서 다시 돌린 토너먼트 45개는 CPU 듀얼 270판을 프레임 단위까지 똑같이 재현했다. 같은 시작 상태에서 돌린 건 몇 번을 돌리든 데이터 하나다.
9,600판
시드를 넣은 뒤 새 fork에서는 토너먼트 1,600개를 돌렸다. 레벨 1, 2, 3을 돌아가며 1,200개, 그다음엔 다른 레벨에는 나오지 않는 레벨 3 CPU 쪽으로 비중을 둔 400개. CPU 듀얼로는 9,600판이고, gap은 -1,612부터 +1,912까지다.
한 판도 빠짐없이 floor(160 / (1 + 10^(gap / 1000)))점이 움직였다. 전부 제로섬이었고, 78명의 레이팅 합은 여전히 94,800이다. 움직인 점수는 1점부터 156점까지였고, 가장 큰 건 250인 Winged Kuriboh가 1,862인 Elemental Hero Lady Heat를 이겼을 때였다.
점수는 옮기지만, 승자를 맞히진 않는다
이 공식은 S = 1000인 Elo고, Elo 곡선은 원래 승리 확률을 나타낸다. 그렇게 읽으면 이 곡선은 강자를 과대평가한다. 9,600판에서 레이팅이 높은 쪽이 이긴 건 62%. 700~1,200점을 앞서도 78%밖에 안 이겼는데, 곡선대로라면 83~94%, 체스 Elo라면 98% 이상이어야 한다.
승패는 레이팅보다 덱이 더 많이 가른다. 승률은 Marcel Bonaparte의 6.8%(44판), Stray Lambs의 7.2%(180판)부터 Jaden Yuki의 72%(332판)까지 벌어지고, 시작 레이팅과의 상관계수는 0.36밖에 안 된다. Manju의 의식 덱은 풀리면 강하지만 말리면 끝이다. 레이팅은 누가 누구를 이겼는지 쌓아 둔 점수지, 예측이 아니다.
앱이 아직 이 공식을 안 쓰는 것도 그래서다. 앱은 게임이 보여주는 값을 기록할 뿐이다. 토너먼트 1,600개 전부는 Research 페이지에서 누구나 확인할 수 있다. 노트, 하네스, 추첨 재현 코드는 저장소에 있다. 확인한 건 한국판(YG8K)뿐이고, 다른 판은 주소가 다르다.