유니티 엔진이 프로젝트를 빌드하면 에셋들은 일정한 규칙대로 assets 파일들에 합쳐진다. 이때 각 에셋들은 유니티 엔진에 기술된 방법대로 Serialized 된다. 에셋의 Dump를 추출할 수 있고, 이를 수정하는 것이 편리하나 특별한 이유에 의해 에셋의 Raw 파일이 필요할 때가 있다. 에셋의 Raw 형태에서 특정한 변수의 값을 확인, 수정하기 위해서는 유니티 엔진의 Serialization에 대해 알아보도록 한다.
유니티 게임 우리말화 도구 베타 버전을 공개하였습니다. 이에 관한 설명은 GitHub의 wiki에서 서술합니다. https://github.com/dmc31a42/UnityL10nTool/wiki
유니티 게임의 한글화와 패치 제작 – 서론 으로 돌아가기
본 강좌는 70~80% 완성되면 바로 공개되며, 잘못된 부분이 있으면 그때 그때 수정하는 것으로 하겠습니다. 아래의 강좌 내용은 언제든지 수정될 수 있습니다. 질문은 댓글로 달아주시거나 GitHub(https://github.com/dmc31a42/UnityGameL10nTutorial/)의 issue를 활용하여주시면 감사하겠습니다.
C#의 Serialization
에셋이 어떻게 Binary로 저장되는지 이해하기 위하여 C#의 Serialization 과정을 먼저 살펴보고 유니티 에셋의 Serialization을 살펴보도록 한다.
https://docs.microsoft.com/ko-kr/dotnet/csharp/programming-guide/concepts/serialization/walkthrough-persisting-an-object-in-visual-studio 에 기술되어있는 방법대로 따라해도 아래의 설명을 따라할 수 있고, 클래스의 인스턴스에 저장된 값을 보기 편하게 수정되어있는 https://github.com/dmc31a42/UnityGameL10nTutorial 의 UnitySerialization 프로젝트를 실행해서 따라해도 좋다. 아래의 설명은 GitHub에 있는 예제를 기준으로 설명한다.
프로젝트를 컴파일 한 후 (Form에 나타나있는 값을 일부 수정한 다음) Form의 ‘X’ 버튼을 통하여 창을 닫으면(Visual Studio에 있는 디버그 중지버튼을 누르면 안된다.) 프로젝트 파일(.csproj)이 있는 위치에 ‘SavedLoan.bin’ 파일이 생성된다.
해당 파일을 Hex Editor로 열어두고, 예제 프로그램을 다시 실행한 모습은 아래와 같다.
Form의 좌측편에 나타난 값을 바꾸고 ‘Save’ 버튼을 눌러(마이크로소프트에서 제공하는 예제는 창을 닫아야 저장이된다. 이 때문에 예제에서는 ‘Save’ 버튼을 만들었다.) Serialization된 결과 파일이 어떻게 바뀌는지 살펴본다. 위의 에제에 나와있는 것 처럼 입력된 값이 ‘10000’, ‘0.075’, ’36’, ‘Neil Black’, ‘1 2 3 4 5 6 7’일 때 ‘SavedLoan.bin’에 저장된 값은 다음과 같다.
offset | value | description |
---|---|---|
000000E0 | 000000000088C340 | Bytes of 10000 as Double |
000000E8 | 333333333333B33F | Bytes of 0.075 as Double |
000000F0 | 24000000 | Bytes of 36 as Int32 |
000000F4 | 0603 | Unknown |
000000F6 | 0000000A | Length of string(below) == 10 |
000000FA | 4E65696C20426C61 | Bytes of ‘Neil Bla’ as string(ASCII) |
00000102 | 636B | (continue from above) ‘ck’ |
00000104 | 09040000000F04 | Unknown |
0000010B | 00000007 | Length of Int array(below) |
0000010F | 00000008 | Unknown |
00000113 | 01000000 | Bytes of 1 as Int |
00000117 | 02000000 | Bytes of 2 as Int |
0000011B | 03000000 | Bytes of 3 as Int |
0000011F | 04000000 | Bytes of 4 as Int |
00000123 | 05000000 | Bytes of 5 as Int |
00000127 | 06000000 | Bytes of 6 as Int |
0000012B | 07000000 | Bytes of 7 as Int |
0000012F | 0B | Unknwon |
Form에 있는 Double, Int, String, Int[](Array)에 있는 값들을 바꿔보면서 ‘SavedLoan.bin’ 파일에 저장된 데이터의 변화를 직접 확인해보도록 한다. 이렇게 클래스의 인스턴스를 바이너리, xml, json, 네트워크 전송 등을 통하여 저장, 전달하는 것과 저장, 전달하는 법에 일정한 규칙을 부여한 것이 Serialization 이다.
Unity Engine Serialization
유니티 엔진에서 에셋을 Serialize할 때 c#과 비교하여 다음과 같은 특징을 가진다.
(주의: 아래의 비교는 검증되지 않은 주관적인 부분이 많이 포함되어 있으므로 이 경고 문구가 제거되기 전까지는 걸러들을 것은 걸러 들어야 한다.)
- C#에서는 Big, Little-Endian이 혼용된 것과 달리. 유니티에서는 Binary 파일에 기록된 모든 value는 Little-Endian로 기록된다.
- C#에서는 클래스가 변경되었을 경우를 대비하여 멤버의 Type과 변수명이 Binary 파일에 기록되어있지만, 유니티에서는 Binary파일에 멤버의 Type과 변수명을 나타내는 어떠한 힌트도 기록되어있지 않고 value만 기록되어있다. (클래스의 버전은 유니티 엔진의 버전으로 관리한다.)
- 위에서 Unknwon으로 표기된 부분은 사실 [MS-NRBF]-170316 스펙을 보면 Type에 관한 Spec이다. 각 Array에는 Type 검증용으로 추정되는 측정 바이트가 기록되어있지만, 유니티의 Array의 경우 배열은 배열의 크기 + 원소의 바이트 변환이 연속적으로 기록되어있을 뿐이다.
Type
Serialize될 때 멤버의 type에 따라 변환되는 데이터의 형태는 다음과 같다. 해당 데이터의 구분은 UABE를 소개할 때도 알려주었다. 상세한 사항은 https://docs.microsoft.com/ko-kr/dotnet/csharp/language-reference/keywords/built-in-types-table 에서 확인할 것.
- Type이 System 변수인 경우
: UInt, Int, float(single), double, string 등
필드에 type과 Field이름, 값이 함께 표시되어있으며, 자식 Field는 존재하지 않는다.
C#에서 사용하는 System type은 수와 문자열, TypelessData(byte[]), Boolean가 있다.- 정수
- 부호가 없는 정수
부호가 없는 정수는 00 00(UInt16기준)이 0을 의미하고, FF FF가 65535가 된다. FF FF에서 1을 더하면 00 00 01이 되고 UInt16은 2 byte type이므로 뒤의 01은 무시된다. 따라서 00 00이 된다. (이를 Overflow라 부른다.).Net
형식C#
형식범위 크기
byteByte byte 0 ~ 255 1 UInt16 ushort 0 ~ 65,535 2 UInt32 uint 0 ~ 4,294,967,295 4 UInt64 ulong 0 ~ 18,446,744,073,709,551,615 8 - 부호가 있는 정수
부호가 있는 정수는 Int16을 기준으로 00 00이 0을 의미하고, FF 7F(유니티의 Serialization은 항상 Big-Endian으로 저장된다)이 32,767을 의미, 00 80은 -32,768, FF FF는 -1을 의미하고, 여기에 1을 더하면 00 00 01이 되지만 원래 Int16은 2byte type이므로 뒤의 01은 무시된다.(이를 Overflow라 부른다.).Net
형식C#
형식범위 크기
byteSByte sbyte -128 ~ 127 1 Int16 short –32,768 ~ 32,767 2 Int32 int –2,147,483,648 ~ 2,147,483,647 4 Int64 long –9,223,372,036,854,775,808 ~9,223,372,036,854,775,807 8
- 부호가 없는 정수
- 부동 소수점
.Net
형식C#
형식근사 범위 유효숫자 크기
byteSingle float ±1.5e−45 ~ ±3.4e38 7
자리4 Double double ±5.0e−324 ~ ±1.7e308 15
-168 Decimaldecimal(-7.9x10e28 ~ 7.9x10e28)
/ (10e0 ~ 10e28)28
-2916
- 문자열과 TypelessData
.Net
형식C#
형식내용물 크기
byteString string 문자열의 길이(Int32) + 문자열 나열(UTF-8) 4+문자열의 길이 TypelessData
(유니티 전용)Raw의 크기(Int32)+이미지나 음악같은 파일의 Raw데이터(Byte) 4+Raw 파일의 크기 - Boolean
.Net
형식C#
형식범위 크기
byteBoolean bool true or false 1
- 정수
- Base Type이 UnityEngine.Object 인 경우
: Type 이 System의 변수가 아닌 객체 중 단독으로 Asset으로 존재하는 경우
Type은 PPtr으로 표시되고 Field 이름이 존재하고, 자식 Field에 해당 Asset의 Assets 파일 위치(type: int, Field 이름: m_FieldID)와 그 Assets 파일에서의 Asset위치(type: SInt64, Field 이름: m_PathID)이 존재한다. Asset Data에서는 가리키는 Asset을 ‘[view asset]’으로 바로 확인할 수 있다.{PPtr<Object>의 구조}Int32(4byte) Int64(8byte) 크기(byte) m_FileID m_PathID 12 - Base Type이 System.Object 인 경우
: 위의 두 조건을 만족하지 않는 나머지 type
필드는 type과 이름으로 구성되어있고, 자식 필드는 해당 필드의 멤버 변수로 구성되어있다. 별도의 에셋으로 저장되지 않은 클래스나 struct의 인스턴스, Array의 경우 본 항목에 해당한다.
별도의 에셋으로 저장되지 않은 클래스나 struct의 인스턴스의 경우 해당 인스턴스의 Serialization결과가 그대로 저장되고, 크기는 그 인스턴스의 크기가 된다.
Array의 경우 array에 들어있는 항목의 ‘갯수’가 Int32로 저장되고(배열이 Serialized 되었을 때의 전체 크기가 아니다), 저장되는 type의 인스턴스가 Serialized 되었을 때 결과를 순서대로 이어서 저장된다. Array 멤버의 크기는 ‘4byte+저장되는 type의 인스턴스가 Serialized되었을 때 byte 크기*항목의 갯수’가 된다. - 그리고 마지막으로 유니티 엔진의 Serialization에서 type 크기가 4 byte의 배수단위인 멤버는 메모리 또는 파일에서 값을 빠르게 읽어오기 위하여 주소가 4 byte 단위로 정렬되어 저장되어야한다. 1, 2 byte 크기의 멤버가 연속해서 배열로 저장될 때는 관계없으나 1, 2 byte 멤버 이후에 4 byte 멤버가 기록된다면 4 byte멤버의 주소를 4 byte의 배수로 만들기 위하여 1, 2 byte멤버와 4 byte멤버 사이에 패딩(NULL; 00h로 채워짐)이 추가된다.
이해를 돕기 위해, 위의 상황을 모두 갖춘 에셋을 하나 만들어보도록 한다. 여기서 중요한 조건은 다음과 같다.
- 위에서 언급된 멤버들이 한번씩 나와야 한다.
- Type이 System 변수인 경우, Base Type이 UnityEngine.Object 인 경우, Base Type이 System.Object 인 경우(인스턴스, Array 모두)가 모두 나와야 한다.
- 1, 2 byte 멤버 뒤에 4 byte 멤버가 있어서서 4 byte 멤버가 시작되어야 하는 지점의 offset이 4 byte의 배수가 아닌 경우가 발생하여 Padding이 있어야 한다.
- 1, 2 byte 멤버를 항목으로 가지는 배열이 있어야 한다.
유니티 에디터를 실행하여 새 스크립트를 만든다. 스크립트의 이름은 관계 없다. 만든 스크립트에는 위의 조건을 만족하는 멤버 변수를 만든다. 아래는 예시로 만든 클래스의 구현체이다.
|using System.Collections;
|using System.Collections.Generic;
|using UnityEngine;
|
|[System.Serializable]
|public class SerializeSubClass
|{
| public byte byte1;
| public int int1;
| public string string1;
|}
|
|public class UnitySerializationExam : MonoBehaviour {
|
| public byte byte1;
| public byte byte2;
| public ushort ushort1;
| public uint uint1;
| public ulong ulong1;
| public sbyte sbyte1;
| public short short1;
| public int int1;
| public long long1;
| public float float1;
| public double double1;
| public decimal decimal1;
| public string string1;
| //
| public bool bool1;
| public UnitySerializationExam UnitySerializationExam1;
| public SerializeSubClass SerializeSubClass1;
| public byte[] bytes1;
| public int[] ints1;
| public SerializeSubClass[] SerializeSubClasses1;
|
|
| // Use this for initialization
| void Start () {
| }
|
| // Update is called once per frame
| void Update () {
|
| }
|}
GameObject로 만들고 이렇게 만든 스크립트를 컴포넌트로 추가한다. 그리고, 추가한 MonoBehaviour의 각 멤버에 값을 임의의 값으로 수정한 결과는 다음과 같다. ‘Base Type이 UnityEngine.Object 인 경우’로 추가한 ‘UnitySerializationExam1’ 멤버의 값을 수정하려는 경우 GameObject를 복제하여 복제된 MonoBehaviour를 선택하면 된다.
만들어진 유니티 프로젝트는 GitHub의 UnitySerialization\UnityProject에서 확인할 수 있다. 이 상태로 Build를 하고, level0 파일을 UABE를 통해 열고, 본인이 작성한 class 이름을 갖는 MonoBehaviour를 ‘Export Raw’를 통해 추출하고 추출한 파일을 Hex Editor로 열어놓고 UABE에서는 ‘View Data’를 통해 열어 두 파일을 비교해보자.
UnitySerializationExam의 구현체에 기록한 멤버 순서대로 저장이 되었다. 이제 raw 파일에 어떤식으로 저장되었는지 확인해보도록한다. 기본적으로 4바이트씩 끊어서 적어놓았고, 8바이트 변수는 8바이트를 붙여서 적었다. ()로 되어있는 부분은 padding 이고, __ 로 표시되어있는 부분은 4바이트를 1바이트씩 쪼개서 표시했을 때 위치를 맞추기 위해 넣은 표기이다(워드프레스에서는 그냥 공백을 가만히 놔두지 않고 지워버린다……)
Offset | Value | Description |
---|---|---|
00000000 | 00 00 00 00 | PPtr<GameObject> m_GameObject -> int m_FileID = 0 |
00000004 | 04 00 00 00 00 00 00 00 | PPtr<GameObject> m_GameObject -> SInt64 m_PathID = 4 |
0000000C | 01 (00 00 00) | UInt8 m_Enabled = 1 |
00000010 | 01 00 00 00 | PPtr<MonoScript> m_Script -> int m_FileID = 1 |
00000014 | 43 00 00 00 00 00 00 00 | PPtr<MonoScript> m_Script -> SInt64 m_PathID = 67 |
0000001C | 10 00 00 00 | Length of string m_Name (=16) |
00000020 | 53 65 72 69 61 6C 69 7A | part of string m_Name (=”Serializ”) |
00000028 | 65 45 78 61 6D 70 6C 65 | part of string m_Name (=”eExample”) |
00000030 | 31 00 00 00 | part of string m_Name (=”1″) |
00000034 | 32 (00 00 00) | UInt8 byte1 = 50 |
00000038 | 1B (00 00 00) | UInt8 byte2 = 27 |
0000003C | 16 0B (00 00) | UInt16 ushort1 = 2838 |
00000040 | 69 42 01 00 | unsigned int uint1 = 82537 |
00000044 | 14 5E 24 00 00 00 00 00 | UInt64 ulong1 = 2383380 |
0000004C | 7F (00 00 00) | SInt8 sbyte1 = 127 |
00000050 | FF 7F (00 00) | SInt16 short1 = 32767 |
00000054 | 30 D3 08 00 | int int1 = 578352 |
00000058 | 14 54 CB FC FF FF FF FF | SInt64 long1 = -53783532 |
00000060 | 2E 50 6A 42 | float float1 = 58.578300 |
00000064 | 06 D8 47 A7 AE C8 46 40 | double double1 = 45.567830 |
0000006C | 2B 00 00 00 | Length of string string1 (=43) |
00000070 | 54 68 69 73 20 69 73 20 | part of string string1 (=”This is “) |
00000078 | 65 78 61 6D 70 6C 65 2E | part of string string1 (=”example.”) |
00000080 | 20 EC 9D B4 EA B2 83 EC | part of string string1 (=” 이것”(은)) |
00000088 | 9D 80 20 EC 98 88 EC A0 | part of string string1 (=(은)” 예”(제)) |
00000090 | 9C EC 9E 85 EB 8B 88 EB | part of string string1 (=(제)”입니”(다)) |
00000098 | 8B A4 2E (00) | part of string string1 (=(다)”.”) |
0000009C | 01 (00 00 00) | UInt8 bool1 = 1(원래는 bool이었음) |
000000A0 | 00 00 00 00 | PPtr<$UnitySerializationExam> UnitySerializationExam1 -> int m_FileID = 0 |
000000A4 | 13 00 00 00 00 00 00 00 | PPtr<$UnitySerializationExam> UnitySerializationExam1 -> SInt64 m_PathID = 19 |
000000AC | 78 (00 00 00) | SerializeSubClass SerializeSubClass1 -> UInt8 byte1 = 120 |
000000B0/th> | 93 E2 01 00 | SerializeSubClass SerializeSubClass1 -> int int1 = 123539 |
000000B4 | 19 00 00 00 | Length of SerializeSubClass SerializeSubClass1 -> string string1 (=25) |
000000B8 | 53 65 72 69 61 6C 69 7A | part of SerializeSubClass SerializeSubClass1 -> string string1 (=”Serializ”) |
000000C0 | 65 53 75 62 43 6C 61 73 | part of SerializeSubClass SerializeSubClass1 -> string string1 (=”eSubClas”) |
000000C8 | 73 31 53 74 72 69 6E 67 | part of SerializeSubClass SerializeSubClass1 -> string string1 (=”s1String”) |
000000D0 | 31 (00 00 00) | part of SerializeSubClass SerializeSubClass1 -> string string1 (=”1″) |
000000D4 | 05 00 00 00 | A number of items in Array bytes1(byte[]) = 5 |
000000D8 | 78 __ __ __ | (UInt8 data) first item of Array bytes1 = 120 |
000000D9 | __ 71 __ __ | (UInt8 data) second item of Array bytes1 = 113 |
000000DA | __ __ 32 __ | (UInt8 data) third item of Array bytes1 = 50 |
000000DB | __ __ __ 5C | (UInt8 data) fourth item of Array bytes1 = 92 |
000000DC | 29 (00 00 00) | (UInt8 data) fifth item of Array bytes1 = 120 |
000000E0 | 04 00 00 00 | A number of items in Array ints1(int[]) = 4 |
000000E4 | BA B1 90 00 | (int data) first item of Array ints1 = 9482682 |
000000E8 | 70 94 03 00 | (int data) second item of Array ints1 = 234608 |
000000EC | 64 62 FF FF | (int data) third item of Array ints1 = -40348 |
000000F0 | FC 05 07 00 | (int data) fourth item of Array ints1 = 460284 |
000000F4 | 02 00 00 00 | A number of items in Array SerializeSubClasses1(SerializeSubClass[]) = 4 |
000000F8 | 77 (00 00 00) | (SerializeSubClass data -> UInt8 byte1) first item of Array SerializeSubClasses1 = 119 |
000000FC | 0E 8A FF FF | (SerializeSubClass data -> int int1) first item of Array SerializeSubClasses1 = -30194 |
00000100 | 19 00 00 00 | (Length of SerializeSubClass data -> string string1) first item of Array SerializeSubClasses1 = 25 |
00000104 | 53 65 72 69 61 6C 69 7A | (Part of SerializeSubClass data -> string string1) first item of Array SerializeSubClasses1 = “Serializ” |
0000010C | 65 53 75 62 43 6C 61 73 | (Part of SerializeSubClass data -> string string1) first item of Array SerializeSubClasses1 = “eSubClas” |
00000114 | 73 65 73 41 72 72 61 79 | (Part of SerializeSubClass data -> string string1) first item of Array SerializeSubClasses1 = “sesArray” |
0000011C | 31 (00 00 00) | (Part of SerializeSubClass data -> string string1) first item of Array SerializeSubClasses1 = “1” |
00000120 | 00 (00 00 00) | (SerializeSubClass data -> UInt8 byte1) second item of Array SerializeSubClasses1 = 0 |
00000124 | 85 0A 14 00 | (SerializeSubClass data -> int int1) second item of Array SerializeSubClasses1 = 1313413 |
00000128 | 19 00 00 00 | (Length of SerializeSubClass data -> string string1) second item of Array SerializeSubClasses1 = 25 |
0000012C | 53 65 72 69 61 6C 69 7A | (Part of SerializeSubClass data -> string string1) second item of Array SerializeSubClasses1 = “Serializ” |
00000134 | 65 53 75 62 43 6C 61 73 | (Part of SerializeSubClass data -> string string1) second item of Array SerializeSubClasses1 = “eSubClas” |
0000013C | 73 65 73 41 72 72 61 79 | (Part of SerializeSubClass data -> string string1) second item of Array SerializeSubClasses1 = “sesArray” |
00000144 | 32 (00 00 00) | (Part of SerializeSubClass data -> string string1) second item of Array SerializeSubClasses1 = “2” |
Raw파일을 UABE의 ‘View Data’를 보면서 하나하나 해석하는 실습을 한번은 해보기를 권장한다. UABE 2.2beta 버전이 나오면서 MonoBehaviour도 Dump와 ‘View Data’가 정상적으로 되지만, 모드 적용을 위해서 raw를 직접적으로 다룰 일이 있을 것이다. UnitySerializationExam 클래스의 멤버를 순서도 바꿔보고 type도 바꿔보면서 asset의 raw 파일을 분석하다보면 unity serialization에 대해 잘 알게될 것이다.
위의 Type을 설명하면서 부동 소숫점 type의 value-binary 변환식을 알려주지는 않았지만, CSharpSerialization 소스파일에서 float, double 형식의 value에서 binary로 바꿀 수 있는 코드가 있으니 이를 참고하여 예제를 따라가면된다(나중에 계산방법을 적어둘것이다)
안녕하세요, 저는 좀 다른 방식으로 유니티 Serialization 을 구현했습니다.
관심있으면 repo 한번 방문해주세요.
https://github.com/Akintos/UnityAssetLib
좋아요Liked by 1명
직접 Deserializer를 구현하셨다니 대단하네요. 위 글은 손으로 직접 Deserialize를 해보면서 원리를 이해하기 위해 보여드린것이고, UABE 2.2beta 버전부터 MonoBehaviour의 Deserialize를 구현해주고 있고, 제가 만든 UnityL10nTool 또한 MonoBehaviour로 제작된 폰트의 교체를 GUI로 지원해주고 있습니다.
좋아요좋아요