#2 월드, 저장, 리플레이, 그리고 저장 형식에 숨어 있던 버그
· Nick
첫 번째 글 이후 코어에는 무언가를 놓을 월드와, 그것을 보관할 파일, 그리고 두 번의 실행이 일치함을 증명하는 방법이 생겼습니다. 여전히 그래픽도 캐릭터도 없습니다. 이번 글의 대부분은 배관 작업이고, 그 일부는 이야기할 가치가 있었던 버그입니다.
타일로 이루어진 월드
월드는 0부터 번호가 매겨진 이산적인 층 위에 놓인 1×1m 타일의 격자입니다. 벽이나 문은 타일 하나입니다. 계단은 두 층을 잇는 특별한 연결이 될 예정이지만, 계단은 아직 만들지 않았습니다. 내부적으로 월드는 지도 전체에 걸친 평평한 레이어들(바닥, 구조물, 구조물 플래그)과, 여기서 파생된 이동 가능 여부 레이어로 이루어져 있습니다. 지도는 버전 카운터가 있는 16×16 청크로 추적하므로, 렌더러나 경로 탐색은 타일을 일일이 비교하지 않고도 “이 영역이 바뀌었나?”를 물을 수 있습니다.
월드의 크기는 만들 때 정합니다. 건설은 사각형을 받는 명령으로 이루어집니다. 어떤 영역에 바닥, 벽, 문을 놓거나 없앱니다. 사각형의 일부가 막혀 있으면 나머지는 그대로 지어지고, 명령은 무엇을 건너뛰었는지 알려 줍니다. 지금은 바닥, 벽, 문의 세 가지 기본 타일 종류가 있습니다. 지도는 이제부터 월드 해시와 저장 파일에 포함됩니다. 느리지만 확실히 올바른 참조 모델과 수천 개의 무작위 명령으로 대조해 검사합니다.
저장과 리플레이
저장과 리플레이는 하나의 파일 컨테이너를 공유합니다. 컨테이너는 라벨이 붙은 섹션으로 나뉘고 각 섹션에는 타입과 버전이 있어서, 월드의 새로운 부분(캐릭터, 욕구, 관계)을 나중에 새 섹션으로 추가해도 옛날 파일이 깨지지 않습니다. 헤더와 본문에는 체크섬이 있고, 본문은 압축되며, 모든 크기는 메모리를 할당하기 전에 한도와 대조하므로, 손상되었거나 악의적인 파일이 게임이 RAM을 전부 잡아먹게 만들 수 없습니다. 타일 종류는 번호가 아니라 이름으로 저장되므로, 종류 목록이 바뀌어도 저장 파일은 계속 쓸 수 있습니다.
리플레이는 처음부터 원했던 것입니다. 시드와 명령 로그입니다. 녹화하는 동안 코어는 100틱마다 확인용 해시도 기록합니다. 리플레이를 재생할 때는 각 명령의 결과와 각 확인용 해시를 그때그때 비교하고, 처음으로 어긋나는 틱에서 멈춥니다. 리플레이는 어느 빌드의 코어가 녹화했는지도 기억하므로, 다른 빌드의 리플레이는 조용히 어긋나는 대신 거부됩니다.
코어 자체는 파일을 전혀 건드리지 않습니다. 스트림을 읽고 쓸 뿐이며, 저장 파일을 어디에 둘지는 게임이 정합니다. 이 경계는 앞서와 같은 금지 API 장치로 강제됩니다.
아직 하지 않은 것: 리플레이 파일을 재생하는 명령줄 도구, 그리고 저장의 게임 쪽 부분(저장 폴더, 자동 저장, 안전한 쓰기).
두 번의 실행이 일치함을 증명하기
이제 결정론 테스트는 하나의 시나리오를 두 개의 별도 프로세스에서 실행합니다. 시드, 건설 명령 스크립트(그중 일부는 일부러 잘못된 것), 그리고 모든 난수 스트림에서 값을 뽑는 테스트용 시스템입니다. 각 프로세스는 이를 세 가지 방식으로 실행하며 매 틱마다 해시를 비교합니다. 처음부터 끝까지 그대로, 저장하고 복원하면서, 녹화된 바이트의 리플레이를 통해서입니다. 그런 다음 두 프로세스는 10틱마다 서로의 해시를 비교합니다.
정말로 실패할 수 있는지 확인하려고 일부러 망가뜨려 봤습니다. 틱에 몰래 끼워 넣은 시드 없는 Random은 테스트를 빨갛게 만들었습니다. .NET이 프로세스마다 다르게 시드를 주는 무작위화된 문자열 해시는, 한 프로세스 안에서는 눈에 띄지 않다가 두 프로세스를 비교했을 때에야 잡혔습니다. 프로세스를 둘로 나눈 이유가 바로 이것입니다.
제가 적어 둔 규칙은, 코어의 한 빌드는 x64와 arm64 프로세서에서 같은 해시를 내야 한다는 것입니다. 그래서 Apple 칩이 든 Mac에서 녹화한 리플레이는 Windows PC에서 재생되어야 합니다. 고정된 기준 해시와 예제 저장 및 리플레이 파일은 바로 이 검사를 위해 존재합니다. 부동소수점 삼각함수와 지수함수는 플랫폼마다 결과가 다르므로, 월드에 영향을 주는 곳에서는 쓸 수 없습니다. 어떤 시스템이 처음으로 이를 필요로 하게 되면 결정론적인 대체물이 필요할 것입니다. 이 기준 파일들은 의도적으로 바꿀 때만 바뀝니다. 해시가 어긋나면 받아들이기 전에 그 변경을 설명해야 합니다.
‘안 돼’라고 말할 수 있는 벤치마크
이제 시나리오, 시드, 틱 수를 받아 JSON 보고서를 출력하는 헤드리스 러너가 있습니다. 보고서에는 틱당 시간의 중앙값과 99번째 백분위수, 에이전트 수, 월드 해시, 틱당 할당된 바이트 수가 들어 있습니다. 첫 번째 시나리오는 16개 층에 걸쳐 512×512 지도를 약 52,000개의 건설 명령으로 만듭니다.
기준선은 기기별로 보관합니다. 노트북에서 잰 시간은 데스크톱에서는 아무 의미가 없기 때문입니다. 회귀는 중앙값이 기준선보다 10% 넘게 높거나 99번째 백분위수가 35% 넘게 높은 경우이며, 그 증가분이 0.05ms보다 클 때만 해당합니다. 거의 빈 틱은 타이머의 해상도 한계에 있기 때문입니다. 한 가지 게이트는 모든 기기에서 유지됩니다. 틱당 할당 0바이트입니다. 이것이 지금 무엇을 측정하는지는 솔직히 말씀드려야겠습니다. 시스템도 캐릭터도 없는 지금은 한 틱이 약 35나노초 걸리므로, 시간 게이트는 이동 마일스톤에서 첫 번째 실제 시스템이 들어오기 전까지는 대부분 잠들어 있습니다. 지금 일을 하는 것은 할당 게이트입니다.
불안정하지 않았던 불안정한 테스트
벤치마크를 만들던 중, 코어는 바뀌지 않았는데도 일부 빌드에서 저장 형식 테스트가 실패하기 시작했습니다. 이 테스트는 압축된 리플레이의 바이트를 하나씩 뒤집어 보며, 로더가 모든 경우를 거부해야 한다고 요구합니다. 한 빌드에서 파일 끝 근처의 한 바이트가 받아들여졌습니다. 이전 빌드에서는 같은 테스트가 통과했습니다.
생겼다 사라진 이유는 이렇습니다. 리플레이에는 그것을 쓴 빌드의 식별자가 들어 있어서 압축된 바이트가 빌드마다 다르고, 따라서 뒤집히는 바이트도 달라집니다. 구멍은 처음부터 있었고, 테스트는 운이 그렇게 따라 줄 때만 그것을 알아챘던 것입니다.
구멍 자체는 이렇습니다. 체크섬이 압축을 푼 뒤의 데이터를 대상으로 했는데, .NET의 압축 해제기가 너그러웠습니다. 끝났다고 알리지 않는 스트림도 받아들이고, 끝 이후의 내용은 무시합니다. 마지막 바이트의 패딩 비트를 뒤집으면 같은 데이터로 풀리는, 유효한 다른 스트림이 됩니다. 그래서 체크섬이 그대로 맞았고, 손상된 파일이 정상 파일로 로드되었습니다.
해결책은 컨테이너의 새 버전입니다. 이제 체크섬은 파일에 저장된 그대로의 바이트를 대상으로 하며 압축을 풀기 전에 검사하고, 압축 해제는 헤더가 말하는 위치에서 정확히 끝나야 합니다. 빈 본문은 압축기가 빈 입력에 아무것도 쓰지 않으므로 압축하지 않고 저장합니다. 검사는 이제 빌드에 의존하지 않는 고정된 압축 스트림과 손상된 파일에 대한 퍼징을 사용합니다. 예제 파일은 새 버전에 맞게 다시 만들었고, 월드 해시는 바뀌지 않았습니다.
그 밖의 것
지금 읽고 계신 이 웹사이트는 이제 첫 페이지에 두 개의 방문자 카운터를 보여 줍니다. 전체 기간의 고유 방문자와 최근 24시간의 고유 방문자입니다. 웹 서버의 페이지 로그로 집계하며, JavaScript도 쿠키도 외부 서비스도 쓰지 않습니다. 주소는 솔트를 친 해시로만 저장하고, 봇은 사용자 에이전트로 걸러내며, 숫자는 몇 분 늦게 반영됩니다. 같은 네트워크 뒤의 여러 사람은 한 명으로 셉니다.
다음
첫 번째 마일스톤인 코어 골격이 끝났습니다. 다음은 M1, Godot 건설 화면입니다. 월드 모델에서 만드는 벽, 문, 방, 층 단면 보기, 90° 단위로 회전하는 카메라, 아트 대신 단순한 큐브입니다. 아직 작업을 시작하지 않았습니다. 로드맵을 참고하세요.