Minecraft screenshot with moon setting in y=-59
개발
기타 개발
프론트엔드
Dungeons Editor 개발 후기
2023-06-12, 14:34 | HoonKun
Dungeons Editor 개발 후기

Minecraft Dungeons 의 세이브 데이터를 수정할 수 있는 툴을 만들어본 후기.

저장소는 이쪽이다.

서론

던전스는... 적당히 평균 힘 250에서 마무리할거라면 노가다로도 충분하지만, 그 이상을 원한다면 실력이 필요하다. 바로 아포칼립스 탑을 때려부술만한 실력이.
그러나 나에겐 그런 것이 없었으니... 아무래도 방법이 없었다.

조금 찾아보니, 던전스의 세이브 데이터를 수정하는 툴이 있었다. 그러나 이런걸 그대로 가져다 쓰기엔 재미가 없다. 게다가 UI도 C# .NET으로 되어있어서 매우 투박했다.
그리하여, 데이터는 수정을 하되 내가 직접 툴을 만들어서 해보자... 같은 느낌이 되었다.

이 글은 그렇게 완성된 게임 세이브파일 에디터를 개발한 후기이다그렇게 당당하게 후기까지 남겨도 되는건가요.

개발 목표

개발 들어가기 전에 생각했던 코어 목표는 이러했다:

  • 인벤토리, 창고에 있는 아이템 데이터를 수정할 수 있을 것
  • 실제 게임 리소스를 직접 포함하지 말고 .pak을 런타임에 해제하여 사용할 것
  • Json 자체의 수정 기능도 제공할 것
  • Linux 와 Windows 모두 지원할 것

Json 지원을 제외하고는 모두 달성되었다. 이제 그 과정에 대해 살펴보자!

주요 개발 과정

개발은 크게는 아래 흐름을 따라 진행됐다.

  1. 암호화된 세이브 파일 원본(.dat)를 복호화하여 Json 으로 변환할 수 있도록 하기
  2. 우선 게임 리소스를 수동으로 해제하여 그것을 사용하여 코어 기능인 데이터 수정기능이 돌아가도록 만들기
  3. 게임 리소스를 프로그램에 직접 포함하지 않게 하고, 실행되는 환경에서 리소스 파일을 찾아 직접 해제하여 사용하도록 하기
    즉, 에디터 실행 중 실시간으로 실행 환경에 있는 게임 리소스(.pak)를 뜯어서 사용해야하고 만약 못찾으면 실행되지 않도록 해야했다.

게임 리소스와 세이브 데이터 복호화

사실 중요한건 역시 열쇠다. 그놈의 열쇠만 있으면 쉽게 할 수 있다.
불행하게도 인터넷을 뒤지니 열쇠가 많이 보였다. 분명히 필요한 열쇠는 두 개인데, 검색되어서 찾아지는 열쇠가... 5개인가 6개였다.

다행히도, 세이브 데이터 복호화 열쇠는 기존에 나와있던 툴로부터 쉽게 구할 수 있었다. 그랬기에, 리소스 데이터 복호화 과정 중 찾은 열쇠들을 전부 때려넣어봐서 하나를 찾아냈다.
그걸 여기에 올리면 큰일이 나므로, 올리지는 못하지만...

코어 기능 UI 개발

이번에도 개인적으로 흥미가 있는 Jetpack Compose 프레임워크를 사용하여, Kotlin Multiplatform 환경에서 돌아갈 수 있도록 개발했다.
의외로 저번 개발 이후로 시간이 많이 지났지만 크게 많이 변한 건 없는 것 같았다.

정말 코어이자 코어는 DungeonsJsonState 로, Json 데이터를 상태로 변환하고 상태를 Json 데이터로 내보내는 역할을 한다.
즉, 처음에 Json 데이터를 상태로 변환하여 Compose를 통해 UI에 표시하고, UI의 상태를 그대로 Json 으로 내보내 저장할 수 있다.

전환 효과

이번 개발에는 저번 NBT 에디터보다 '전환 효과' 에 많이 신경을 썼다.
Compose 는 AnimatedContent 를 비롯하여 AnimatedVisibility, Crossfade 등 많은 컨텐츠 전환을 위한 API를 제공하는데, 그것이 꽤 쓸만했다.

하지만 표시하는 데이터가 많아 만족할 수 있을 수준의 부드러움은 구현해내지 못했다.
몇 가지, .alpha 수정자와 .blur 수정자를 .graphicsLayer 로 이동하거나, 재구성을 최소화하는 리팩터링을 진행하긴 했다.
결론적으로 나아지기는 했으나, 여전히 만족할 만한 수준은 아니었다.

Enchantment 빛 효과

인게임 내에서, Enchantment 아이콘은 반짝거린다. 메인 레이어 위에, 세 단계로 아이콘의 일부분이 반짝거린다.
이것이 어떻게 일어나는지를 살펴본 바, 메인 텍스쳐와 빛 효과 데이터 패턴 텍스쳐가 나뉘어있었다.

PoisonFocus의 메인 텍스쳐와 빛 패턴

PoisonFocus 의 메인 텍스쳐(왼쪽) 과 빛 패턴(오른쪽)

저기에서 오른쪽의 붉은 부분에 해당하는 왼쪽에서의 영역이 가장 먼저 빛나고, 잠시 뒤에 녹색 부분이 빛나고, 또 잠시 뒤에 푸른 부분이 빛난다.
이거를 구현해보고 싶었다.

사실 처음에는 Compose 의 ColorFilter 를 사용해 ColorMatrix 를 입혀서 구현했다.
처음에는 나쁘지 않았다. 단지, 재구성할 때마다 20개 길이인 FloatArray를 세 개씩 마구 만들어낼 뿐이었다.

이후 최적화 중에서 이 방식은 변하게 된다.
사실 이미지 크기가 그렇게 크지 않으므로, 픽셀 하나하나에서 bit masking 을 통해 R, G, B 값만 남기고 날려주는 것도 그렇게 느리지 않았다.
ColorFilter 를 사용한 방식의 한계는 해당 로직이 '그려질 때' 발생하기 때문에, 매번 그릴 때마다 다시 수행해줘야한다는 것이었다.
그런데 bit masking 을 통한 방식은 원본 이미지만 있으면 언제든 수행할 수 있기 때문에, 초기 로딩 시에 모든 필터가 적용된 것을 미리 만들어두고, 그릴 때는 아무런 연산 없이 그대로 올릴 수 있었다.

결론적으로 아래처럼 매우 성공적으로 구현할 수 있었다. PoisonFocus의 반짝임 효과가 적용된 결과

PoisonFocus의 반짝임 효과가 적용된 결과. 용량이 커서 안보일 수 있지만 좀만 기다려보자.

게임 리소스 인덱싱 및 읽기

이것은 기존에 나와있던 툴이 사용하던 C#으로 작성되어있던 라이브러리를 그대로 Kotlin 으로 변역했다.

번역

처음에는 C#의 BinaryReader 를 Java의 DataInputStream 으로 번역했는데, 리소스 파일은 4-5 GB는 되므로 DataInputStream 를 사용하기엔 무거웠다.
그래서 FileInputStream 을 사용하려고 했는데, 이 친구는 또 markreset 을 지원하지 않았다.
결론적으로는 FileChannel 을 사용하도록 구현했다.

게임 리소스인 .pak 파일은 인덱싱 영역과 실제 파일의 내용으로 나뉘는 듯 했다.
이 .pak 에는 어떤 어떤 파일들이 있고, 얘네들 각각의 디렉터리 위치는 어떻고, 실제 .pak 파일 내에서의 위치(offset)은 어디고... 뭐 이런 내용이 인덱싱에 해당하는 부분이다.
즉, 이 인덱싱에 해당하는 부분만 가장 먼저 읽은 뒤 필요한 데이터들만 읽도록 변경했고, 속도가 기존에 모든 파일을 ByteArray 형태로 메모리에 올렸을 때 약 5초 이상 걸리던 것이 1초도 걸리지 않는 속도로 향상되었다.

물론 모든 사항을 FileChannel 을 사용하여 구현하지는 못했다. 암호화된 부분들은 어쩔 수 없이 ByteArray 가 필요했기 때문에... 그쪽 부분은 기존 DataInputStream 을 사용할 수밖에 없었다.

텍스쳐 디코딩

UE4에서 텍스쳐는 여러 포맷으로 압축되어있다. 그 중 이번 작업에서는 DXT5, DXT1, A8R8G8B8 정도를 만났다.
이 디코딩에서 꽤 많은 삽질을 했다. C#에서의 숫자 타입 캐스팅과 Kotlin 의 숫자 타입 캐스팅을 모두 이해하고 있어야 했는데, 그렇지 않았던 것 같다.

주로 세 가지였다:

  • 부호가 있는 정수에 대해 음수를 우측으로 시프팅하면 왼쪽이 1로 채워진다
  • 부호가 있는 정수에 대해 음수를 더 큰 데이터형으로 변환하면 왼쪽이 1로 채워진다
  • 계산 중에 범위를 벗어나면 잘린다.

즉, 아래 코드들은 서로 다른 결과를 나타낼 수 있다:

// a: Int, b: Int 이고 a, b 증 하나가 매우 큰 값일 때 actual 과 expected 는 달라진다.
val actual = (a + b).toLong()
val expected = a.toLong() + b.toLong()

계산 중에 벗어난 범위는 잘린다.

// a: Byte, b: Byte, c: Byte 이고, 셋 중 하나가 음수일 경우 actual과 expected는 달라진다.
val actual = (a.toInt() shl 0) or (b.toInt() shl 8) or (c.toInt() shl 16)
val expected = ((a.toInt() and 0xff) shl 0) or ((b.toInt() and 0xff) shl 8) or ((c.toInt() and 0xff) shl 16)

Byte를 toInt() 할 때 Byte가 음수면 왼쪽 비트들이 1로 채워진다.

이 코드의 경우에는, Byte가 당연히 8비트고 거기서 더 큰 데이터형으로 늘어나봤자 왼쪽은 0으로 채워질거란 생각에 빠져있었다. 실제론 그렇지 않았는데...
즉, actual로 써도 될 줄 알았지만 expected 가 맞았다는 것이었다.

그래서 이 텍스쳐 디코딩에 생각보다 시간을 썼다.

디코딩에 실패한 이미지 네 개와 성공한 이미지 하나가 한 줄로 나란히 붙어있다.

디코딩 삽질의 극히 일부. 적어도 색상 관련 삽질은 여기에 나타나있지 않다.

기타, C#에서는 포인터 캐스팅이나 포인터에 +연산을 통한 배열 인덱스 증감 등 여러 테크닉이 묻어있어서 그걸 Kotlin 으로 바꾸느라 애좀 먹었다.

Json 지원의 포기

초기에는 Json을 지원하는게 그렇게 어렵지 않을 줄 알았다.
그러나 직접 하려고 보니 크게 두 가지 문제가 있었다:

타입

일단 타입이 가장 큰 문제다. Kotlin 은 타입이 명확해야하지만 Json은 그렇지 않다.
이걸 Serialization 하려면 모든 세이브파일 내 데이터 구조를 파악하고 구조화해야한다.

Json 라이브러리를 쓰면 타입을 신경쓰지 않아도 될 수 있지만, 어디까지나 이 툴은 Json 에디터가 아니라 세이브파일 에디터이기 때문에 수정 결과물을 게임에 넣었을 때 이상하게 동작하지 않으려면 적어도 '옳은 타입'이 무엇인지는 알아야할 필요가 있었다.

데이터 일관성

Json 편집만 지원하는 것이 아니라 GUI 적인 편집 기능도 같이 제공할 것이었기 때문에, GUI로 수정한 사항은 Json 에도 반영되어야하고, 그 역도 마찬가지이다.
사실 이 자체만으로는 딱히 문제가 있지는 않다. 정말 문제는 Json 에디팅을 통해 이상한(유효하지 않은) 값을 넣었을 때 GUI 측의 핸들링이 문제였다.

GUI 에디팅은 UI 자체를 통해 사용자가 올바른 값만 넣도록 할 수 있지만, Json 을 직접 수정하게 하면 그럴 수 없었다.

결론적으로, Json 에디팅은 그렇게 큰 메리트가 있다고 판단하지 않아 제외하기로 결정한다.

릴리즈 배포!

Github의 릴리즈 탭을 써본건 이번이 두 번째인 것 같다.
이전에 doodle 안드로이드 라이브러리를 배포했었는데, 사실 기억의 저 너머로 사라져서 저장소를 private 으로 돌리고 잊어버렸을 것이다.

다행히 Jetpack Compose Multiplatform 을 썼기 때문에, JVM 및 필요한 모든 것들을 하나로 합쳐서 뽑아줬다.
딱히 큰 문제 없이 하나의 소스로 리눅스 및 윈도우즈를 지원할 수 있었다. (사소한 File.separator 같은 문제를 제외한다면)

항상 그랬지만 배포하고 나면 버그가 보이더라. 그래서 pre-release 이후로 3 번의 배포를 더 했다.

진짜 후기

의외로 많은 것을 했다!!
사실 초반에는 PakReader 도 만들지 못할 것이라 예상했고, 무엇보다 대충 나만 써야지 하고 만들다가 흐지부지 될 것이라 예상했다.

문서화도... 개발 기록은 문서화하지 않아서 남아있지 않으나 적어도 README.md는 한국어와 영어로 작성해서 다른 사람들이 만약에 쓰러 온다면 확인할 수 있도록 했다.

누군가 다른 사람이 쓸거라는 믿음은 없지만... 적어도 제대로 끝마무리를 지었다는 점에서 나에게 의미가 크다.

나름 성능도 신경써서 만족할 수준은 아니어도 조금이지만 개선해보았고... 여기에 다 적지는 못했어도 새로 알게된 사실도 많았다.

항상 컴포즈로 뭔가를 만들면 재미난게 나오는 것 같아서 좋다. 다음 장난감은 더 재미난걸로 해야지. 히히.

키위새의 아무말 저장소
  • 개발
  • 마인크래프트
  • 생명과학II
  • 아무말
Blog Logo