저희는 패스 오브 엑자일에 오랫동안 존재했던 "텍스트가 뭉개지는" 버그를 수정한 UI 프로그래머 벤과 이야기를 나누어 보았습니다. 버그 자체에 관심이 있는 분들이 있었기 때문에, 벤이 버그 수정 일지를 써 주었죠. 아래에서 확인해 보세요!



드디어 플레이어들이 6년 동안 겪고 있었던 악명 높은 "뭉개지는 텍스트" 버그의 해결 방법을 찾았다는 소식을 발표한 후에, 이 해묵은 문제를 수정한 과정을 알고 싶다는 관심을 표해 주신 분들이 계셨습니다. 언젠가 기술적인 정보를 담은 글을 써 볼까 하던 차라, 이번 기회에 써 보았습니다. 텍스트가 "뒤죽박죽"이 됐다거나 "손상"됐다거나 "크랭글"됐다는 등, 이 버그는 여러 가지 표현으로 불렸죠. 내부적으로는 "뭉개진다"는 표현을 썼기 때문에 이 글에서도 그렇게 부르겠습니다.

이 버그는 2016년 4월 25일에 코드베이스에 처음 나타나, 2.3.0 예언 리그와 함께 프로덕션 빌드에 적용되었습니다. 당시 출시 예정이었던 패스 오브 엑자일 Xbox 버전을 지원하기 위해 텍스트 엔진을 리팩터링하는 과정에서 버그가 생겨난 겁니다.

증상

그동안 대부분의 플레이어가 한 번쯤은 이 버그를 경험했을 거라 해도 무리는 아닐 것입니다. 보통 장시간 게임을 하다 보면 이 버그가 나타나지만, 일부 플레이어는 유독 이 버그를 많이 경험하기도 했습니다. 이 버그는 게임의 어느 텍스트에나 구별 없이 나타났으며, 두 가지 증상이 있었습니다. 한 가지는 커닝, 즉 개별 글리프 간의 간격이 너무 넓거나 너무 좁은 현상이었습니다.



또 한 가지는 개별 문자가 엉뚱한 글리프로 잘못 표시되는 현상이었죠.


관찰력이 예리한 일부 플레이어들은 두 번째 경우에 일종의 암호 해독표를 적용하면 원래의 텍스트를 복원할 수 있다는 사실을 알아내기도 했습니다.

예: "Pevpf`^l D^j^db" -> "Physical Damage"의 경우. ^가 "a"에 해당합니다.

저희가 항상 이상하다고 생각했던 점 하나는, 대문자와 소문자의 오프셋이 다르다는(또는 대문자에 오프셋이 전혀 없다는) 점이었습니다.

원인 규명

이 문제에 관해 처음으로 버그 티켓이 접수된 것은 2016년 6월 4일이었습니다. 예언 리그 출시 직후에 게시판에 올라온 제보를 취합해 작성한 티켓이었죠. 제일 큰 장애물은, 이 버그가 아주 드물게 아무 때나 발생했기 때문에 사내에서 사용하는 컴퓨터로 이 버그를 안정적으로 재현할 방법을 찾지 못했다는 점입니다. 제가 듣기로는 한 프로그래머의 컴퓨터로 한두 번밖에 재현이 되지 않았다고 합니다. 버그가 발생했을 때 메모리에 남은 기록이 원인을 파악하는 데 단서가 되기 때문에, 버그를 재현하는 것은 대단히 중요합니다. 재현 방법을 파악할 때까지는, 오직 추측에 의존해 이런저런 해결책을 적용해 보고 버그 제보가 그만 들어오기를 바라는 것이 최선이었죠. 아무 단서가 없었고 게임 진행에 결정적인 영향을 주는 문제도 아니었기 때문에, 신규 콘텐츠 개발과 그 외의 버그 수정에 시간을 할애하기 위해 이 버그는 중요성이 낮은 문제로 분류되는 처지가 되었습니다.

(저를 포함해) 많은 개발자들이 원인을 찾으려고 노력하는 동안, 사용자 제보 링크는 매달 늘어나며 이 알쏭달쏭한 문제의 존재를 끊임없이 상기시켜 주었습니다. 쌓여 가는 제보와 스크린샷, 저의 개인적인 경험을 통해 저는 아래의 내용을 파악할 수 있었습니다.

  • 이 버그는 특정 텍스트 뭉치나 문자열이 아니라, 개별 폰트 스타일(서체, 크기, 이탤릭/볼드 상태의 조합)에 영향을 미칩니다.
  • 텍스처 생성, 손상 또는 아틀라스 관련 문제는 아닌 듯했습니다. 글리프 중에 일부가 잘리거나 반으로 잘린 것은 없었기 때문입니다. 프로그래머의 컴퓨터에서 드물게 버그가 발생했던 사례도 이런 가설을 뒷받침해 주었습니다.
  • 로그아웃해도 대부분의 경우 문제가 해결되지 않으며, 클라이언트를 다시 시작해야 해결되었습니다.
  • Xbox, PlayStation, MacOS에서는 이 버그가 발생한다는 제보가 없었습니다. 이를 근거로 저는 텍스트 엔진의 특정 부분으로 원인의 범위를 좁힐 수 있었습니다.

스컬지 출시 시점을 기해, 버그 제보가 더 잦아지기 시작했다는 생각이 들었고 제가 게임을 하는 동안에도 이 버그를 더 자주 경험하기 시작했습니다. 저는 직접 경험했던 버그 발생 상황을 기록하고 플레이어들이 올린 이미지를 수집하여 가설을 세워 보았지만, 그래도 "게임을 많이 하는 것" 외에는 버그를 재현할 방법을 찾을 수가 없었습니다. 그러다가 2주쯤 전 업무에 여유가 생기면서 또 한 번 진지하게 원인을 규명해 보기로 하고, 며칠 동안 관련 문서를 읽고 그 구조를 파악하며 텍스트 엔진을 철저히 파헤쳐 보았습니다.

해결책

텍스트 엔진의 코드를 파헤치는 동안, 마침내 다음 함수를 발견했습니다.

SCRIPT_CACHE* ShapingEngineUniscribe::GetFontScriptCache( const Resources::Font& font )
{
    const auto font_resource = font.GetResource()->GetPointer();
    // `font_script_caches` here is a map of `const FontResource*` to `SCRIPT_CACHE` values
    auto it = font_script_caches.find( font_resource );
    if( it == std::end( font_script_caches ) )
        it = font_script_caches.emplace( std::make_pair( font_resource, nullptr ) ).first;
    return &it->second;
}

프로그래머가 아닌 분들을 위해 설명하자면, 이 함수는 특정 폰트 리소스를 참조로 받아들여 메모리에서의 리소스 위치를 키(찾는 값)로 사용해 SCRIPT_CACHE 데이터 객체를 찾고, 이 객체가 이미 존재하지 않을 경우 새로 생성합니다. 함수는 그런 다음 해당 SCRIPT_CACHE 객체에 포인터를 반환하고, 함수 호출자가 저장되어 있는 SCRIPT_CACHE 값을 수정합니다. 복사를 하지 않고 수정하는 이유는, 복사할 경우 값의 변화가 font_script_caches 맵에 영구적으로 기록되지 않기 때문입니다.
여기서 SCRIPT_CACHE 객체는 (클라이언트의 Windows 버전에서만 사용되는) Windows Uniscribe 라이브러리가 사용하는 불투명 데이터 객체입니다. Uniscribe 공식 문서에는 이 객체가 정확히 무슨 정보를 저장하는지는 나와 있지 않고, 응용 프로그램이 사용하는 "문자 스타일" 하나당 이 객체를 하나씩 유지해야 한다는 내용만 있습니다. 하지만 텍스트가 뭉개지는 버그의 증상으로 미루어 보면, 이 객체가 최소한 문자를 글리프 텍스처에 매핑하는 작업, 그리고 커닝에 사용된다는 점을 추론할 수 있습니다.

언뜻 보기에 이 함수는 전적으로 합리적인 기능을 수행하는 것으로 보입니다. 그렇기 때문에 그동안 원인을 파악하지 못했던 것일 테고요. 사용되지 않는 폰트 리소스를 우리 리소스 매니저가 언로드할 수 있다는 데 생각이 미쳐야만 문제가 있다는 것을 알 수 있죠. 이런 상황에서 리소스 매니저가 (서체, 스타일 또는 크기가 다른) 다른 폰트를 동일한 위치 메모리에 로드하여, 새 폰트가 이전 폰트의 SCRIPT_CACHE를 재사용하게 되면 버그가 발생합니다. 여기까지 파악한 저는 두어 번의 테스트를 거쳐 이 가설이 맞다는 것을 확인할 수 있었습니다.

모든 폰트가 같은 스크립트 캐시를 사용하게 만들었더니 게임을 실행하자마자 이 현상이 나타난 것입니다.


만세! 두 가지 증상이 모두 나타났죠. 이로써 두 증상이 별개의 문제가 아니라 하나의 문제에서 발생한다는 점도 확인이 되었습니다. 그 후에는 의도적으로 폰트를 최대한 많이 로드했다가 언로드해서 새 폰트가 이전 폰트의 메모리 위치를 점유하게 하는 방식으로 버그를 재현할 수 있었습니다.


문제의 원인을 알고 나서 해결하는 데는 몇 가지 방법이 있었습니다. 폰트가 언로드될 때마다 Resource::Font 객체에 속하는 SCRIPT_CACHE 객체를 옮기고 이전의 SCRIPT_CACHE를 삭제하는 방법이 있고, 폰트 하나하나를 실제로 차별화하는 요소인 폰트의 서체와 크기, 스타일에 따라 메모리 주소의 찾기 값을 교체하는 방법이 있었습니다. 어느 방법이든 문제는 해결해 주겠지만 각각 장단점이 있기 때문에, 더 큰 시스템에 잘 들어맞는지를 따져 보아야 했습니다.

요약

버그의 원인 자체는 별로 흥미로울 것은 없었습니다. 메모리 주소는 이론에서나 현실에서나 재사용될 수 있으므로, 포인터를 키로 사용할 때는 조심해야 한다는 점을 알면 되는 거였죠. 그래도 이 버그는 기억에 오래 남을 것 같습니다. 증상도 유난히 기이했고 원인을 찾기가 까다롭기도 했지만, 이렇게 오랫동안 해결되지 않았다는 악명만으로도 기억에 남을 만하죠. 어찌 보면 제 두뇌가 풀 "대형 미스터리"가 하나 줄어든 셈이라 조금 서글프기까지 하네요. 흥미를 갖고 파고들 만한 미스터리를 하나 더 찾아야 할까 봅니다.

그동안 이 문제와 그 외의 문제들을 제보해 주신 모든 분께 감사드립니다! 소프트웨어 개발과 (특히) 버그 수정의 세계란 참 오묘해서, 극히 사소한 이유로 정말 희한한 버그가 생길 수 있습니다. 상세한 버그 제보는 원인을 파악하고 저희가 직접 문제를 재현해 보는 데 대단히 소중한 자료가 됩니다. 그래야만 어둠 속을 더듬고 다닐 필요 없이 해결책을 개발해서 테스트할 수 있죠.


R.I.P T l e e v
글 작성자: 
카카오게임즈

게시판 글 신고

신고 계정:

신고 유형

추가 정보