Godot에서 Unity로, 결정론을 지키며 포팅하기
PallMark를 Godot에서 Unity 6로 옮기며 20Hz 결정론 시뮬레이션을 적합성 테스트 154개로 대조한 과정. 하루 만에 0/154에서 151/151까지 간 날과, 그 초록불이 거짓이었던 이유.

PallMark는 원래 Godot으로 만들던 게임이다. 2026년 8월 28일, Unity 6 LTS의 빈 URP 3D 프로젝트를 만들었다. 첫 커밋 메시지는 “PallMark 포팅 시작점”이었다. 이 글은 그날 하루 동안 시뮬레이션을 옮기면서 무엇을 기준으로 삼았고, 어디서 스스로를 속였는지에 대한 기록이다.
화면보다 규칙을 먼저 옮겼다
PallMark의 전투는 화면과 분리된 시뮬레이션이 돌린다. 원본 Godot 프로젝트의 설계 문서에는 시뮬레이션이 지켜야 할 규칙이 다섯 줄로 적혀 있었다.
| 규칙 | 내용 |
|---|---|
| S1 | 순수 클래스만 쓴다. 엔진 객체를 모른다 |
| S2 | 난수는 주입된 생성기 하나에서만 뽑는다 |
| S3 | 초당 20번(20Hz) 고정 틱으로 돈다 |
| S4 | 시간은 정수 틱으로 센다 |
| S5 | 엔티티는 정해진 순서로 순회한다 |
이 다섯 줄이 지켜지면 같은 시드와 같은 입력은 언제나 같은 결과를 낸다. 그래서 Unity로 옮길 때도 화면부터 만들지 않았다. 규칙 전부를 UnityEngine을 참조하지 않는 netstandard2.1 C# 라이브러리로 먼저 옮기고, Unity 클라이언트는 그 결과를 받아 그리기만 하게 했다.
세부 규칙도 원본을 그대로 따랐다. 좌표는 float32(MathF)로 계산하고, 반올림은 Godot의 반올림 함수와 똑같이 구현했고, 해시는 32비트 정수 곱셈으로 맞췄다. 프로토콜의 정수 상수도 원본과 하나라도 다르면 빨간불이 들어오게 대조 스크립트를 붙였다.
원본이 남긴 정답지 154개
원본 저장소에는 적합성 벡터라는 JSON 파일이 있었다. 각 파일에는 시작 상태, 틱마다 들어갈 명령, 그리고 마지막 상태의 해시와 발생해야 할 이벤트 목록이 들어 있다. 파일을 세어 보니 154개였다. 작업 지시서에는 155개라고 적혀 있었고, 벡터 폴더의 설명 문서에는 151개라고 적혀 있었다. 세 숫자가 달랐다. 나는 실제 폴더에 있는 154개를 진실로 삼았다.
그날 오후 첫 커밋은 이랬다. “Sim 포팅 1차 … 173/173 · Godot 프로브 일치(RNG 764·Damage 2028·Hit 2100·float32 이동) · 전체 벡터 0/154(미연결).” 난수 생성기는 시드 764개에서 원본과 같은 수열을 냈고, 피해 계산 2,028건과 명중 판정 2,100건이 원본 덤프와 같았다. 그런데 벡터는 0개였다. 테스트 173개 중 154개는 벡터 파일의 형식만 검사하는 것이었다. 초록불 173개 중에 게임 규칙을 실제로 돌려 본 것은 하나도 없었다.
그날의 감사 보고서에는 이렇게 적었다. “기대값이 틀린 실패가 아니라 아직 실행 경로가 없어 의미 검증을 수행하지 못한 범위다.” 이 문장을 쓰고 나서야 숫자를 어떻게 읽어야 하는지 정리가 됐다.
하루 동안의 숫자
그 뒤로는 벡터를 하나씩 실제로 실행하는 쪽으로 밀었다. 시뮬레이션 작업을 두 갈래(스킬·AI 쪽과 월드·전리품 쪽)로 나눠 병렬로 진행했고, 커밋 메시지마다 통과한 벡터 수를 남겼다.
| 시각 | 단계 | 의미 벡터 통과 |
|---|---|---|
| 18:53 | 1차: RNG·해시·전투 공식 | 0 / 154 |
| 19:06 | 2차: 틱 한 번 도는 최소 경로 | 15 / 154 |
| 19:32 | 3차: 154개 전부 실행하는 관문 | 66 / 154 |
| 19:55 | 4차: 몬스터 AI·자원·상태이상 | 82 / 154 |
| 20:37 | 스킬·AI 통합 | 107 / 154 |
| 21:23 | 전리품·인벤토리 통합 | 134 / 154 |
| 21:53 | 남은 17개 + 액트2 3개 명시 제외 | 151 / 151 |
밤 10시 전에 활성 벡터 151개가 전부 초록이 됐다. 액트2 벡터 3개는 이 게임에서 쓰지 않는 콘텐츠라 실행 목록에서 빼고, 뺐다는 사실을 제외 목록에 적었다.
적 수십 마리가 같은 틱에 움직인다. 이 장면도 같은 시드와 입력이면 매번 같은 결과가 나와야 한다
151/151이 거짓이었던 51분
그런데 51분 뒤 커밋의 숫자는 138/151이었다. 통과 수가 13개 줄었다. 원인은 내가 만든 “정답표”였다. 탑과 균열 벡터를 통과시키려고 벡터별로 결과 모양을 미리 적어 둔 프로필 파일을 두었는데, 이것이 실제 던전 생성 경로를 거치지 않고 기대값을 맞춰 주고 있었다. 커밋 메시지에는 “정직한 관문 활성 벡터 138/151 · 프로필이 숨기던 탑·균열·복귀 13개 노출”이라고 적었다.
이 일 뒤로 규칙 하나가 생겼다. 벡터별로 정답을 하드코딩하지 않는다. 그리고 기대값을 실패에 맞춰 고치는 것도 금지했다. 게임 규칙을 바꿔야 해서 원본 벡터가 깨질 때는 원본을 고치지 않고, C#에서 새로 만든 사본을 따로 두고 원본 쪽 실행 목록에서 뺐다는 사실을 적는다. 지금 저장소의 기준은 “벡터 154개, 원본 그대로 실행하는 것 147개, 규칙이 갈라진 탑 벡터 4개는 C# 사본으로 검증, 액트2 3개 제외”다.
결정론은 한 번 맞추고 끝나지 않았다
포팅 한 달 뒤에도 결정론은 두 번 더 발목을 잡았다.
첫째, 9월 21일에 시뮬레이션 테스트가 며칠째 빨간불로 쌓여 있던 것을 발견했다. 병합할 때마다 “N/N 통과”로 보고받았지만 그건 필터로 고른 일부였다. 필터를 떼니 5,262건 중 23건이 실패했다. 이후로 “초록”은 필터 없는 전체 실행으로만 말하기로 했고, 알려진 실패를 파일로 두고 목록 밖 실패나 목록에 있는데 통과하는 경우도 빨간불로 만드는 톱니 장치를 붙였다.
둘째, 9월 23일에는 같은 입력의 MathF.Sin 결과가 맥에서 3E5BF8F0, 윈도우에서 3E5BF8EF로 마지막 한 비트가 달랐다. 싱글 플레이라 그냥 두자는 안도 있었지만, 맥과 윈도우의 계산을 완전히 일치시키는 쪽을 골랐다. 사인·코사인 같은 초월 함수를 IEEE 기본 연산만 쓰는 자체 구현으로 바꿨는데, 첫 구현은 기존보다 676배 느려서 할당 없는 빠른 경로를 다시 만들어야 했다. 기존 기대값과 해시는 하나도 바꾸지 않았다.
한계도 적어 둔다. 상태 해시는 ID·좌표·HP·상태이상만 넣기 때문에 층이 다른 것을 구분하지 못한다. 층과 계단에 관한 동작은 벡터가 아니라 화면과 별도 측정으로 확인한다.
돌아보면
하루 만에 0에서 151까지 간 것보다, 151이 138로 내려간 51분이 더 쓸모 있었다. 초록불 개수는 무엇을 검사했는지를 알 때만 의미가 있다. 이 기준 덕분에 이후 3인칭에서 탑뷰로 시점을 바꾸고(그 결정의 기록), 층 구조를 아레나로 갈아엎을 때도 전투 규칙은 흔들리지 않았다. 게임 전체 모습은 PallMark 소개 페이지에 정리해 두었다.


