2026-10-25

ARM 서버에서 Palworld UE4SS 실행하기 ⑤ — TrueMercy segfault와 잘못 찾은 GNatives 함수 표

TrueMercy의 지속 피해 훅에서 발생한 segfault를 추적하니 UE4SS가 .init_array를 GNatives로 오인하고 있었다. 바이트코드 인자 해석이 깨진 증거, 함수 표 검증 수정, 실제 플레이와 최종 Docker 검증을 기록한다.

서버는 켜졌지만 지속 피해가 들어오자 죽었다

4편에서는 Box64의 콜백 슬롯 고갈을 수정하고, UE4SS 초기화와 Lua 실행, 서버 API 응답까지 확인했다. 당시에는 TrueMercy의 실제 전투 효과를 검증하지 않았다고 범위를 남겨 두었다.

그다음 단계에서 새 문제가 드러났다. TrueMercy가 로컬 플레이에서는 동작하는데, 전용 서버에서 지속 피해가 발생하면 프로세스가 죽었다. 최초 보고에서 눈에 띈 필드는 다음과 같았다.

sig=11
rip=0x7b8074d
fault=0x2806242800004c
Segmentation fault (core dumped)

결론부터 말하면, 이번에 수정한 곳은 UE4SS의 GNatives 탐색 코드였다. Unreal의 바이트코드 명령을 실행하는 함수 표 대신 ELF의 초기화 함수 배열을 찾아 놓고 사용하고 있었다. TrueMercy의 SlipDamage 훅은 이 오류를 드러내는 실행 경로였다.

수정 후에는 실제 플레이 중 지속 피해 함수가 14회 정상 반환했고, 화상 피해를 0으로 줄여 HP 1을 유지하는 로그도 확인했다. 플레이어가 추가 테스트 후 정상 동작을 확인한 뒤, 추적 코드를 제거한 최종 빌드를 다시 검증했다.

이번 기록의 실행 환경과 범위

실험은 2026년 10월 25일에 진행했다. 실행 구조는 앞선 글과 같다.

ARM64 Linux 호스트
└─ ARM64 Docker 컨테이너
   └─ Box64
      └─ x86_64 Palworld 서버
         └─ x86_64 UE4SS
            └─ TrueMercy Lua 훅
항목 이번 검증 환경
호스트 Ubuntu 24.04 AArch64
Box64 v0.4.3, 리비전 e694f2ca8
Palworld v1.0.5.102999
Unreal Engine 5.1.1
UE4SS 빌드 x86_64 크로스 컴파일, headless 구성

작업은 ue4ss-linux-palworld의 2397c7b 커밋을 기준으로 앞선 수정이 적용된 작업트리에서 진행했다. 이 글의 수정은 해당 작업트리에 적용한 것으로, 업스트림에 병합하거나 릴리스했다는 의미는 아니다.

아래의 주소와 기계어는 이번 게임 바이너리에서 확인한 값이다. 특히 0xc1534a0을 다른 버전에도 그대로 넣는 고정 주소 패치로 사용해서는 안 된다. 실제 수정도 이 주소를 하드코딩하지 않고 실행 시 조건을 검증해 찾도록 했다.

모드 로드와 전투 중 콜백 실행은 별도 단계였다

전투 문제에 앞서 Mod 'TrueMercy' not found or not installed 같은 메시지 때문에 모드 경로도 조사했다. 이때는 Lua 모드를 C++ 모드 시작 단계에서 다시 확인하며 오해를 주는 경고가 나오는 문제를 수정했다.

로그에서 Lua와 C++ 시작 단계를 구분하고, Lua 진입 스크립트가 성공적으로 실행됐을 때 다음 표식을 남기도록 했다.

Lua mod 'TrueMercy': main.lua execution complete.

이 표식은 main.lua의 최상위 실행이 끝났다는 증거다. 나중에 전투 중 호출될 모든 훅까지 정상이라는 뜻은 아니다. 이번 segfault는 그다음 단계에서 발생했다.

TrueMercy가 실제로 걸어 둔 훅의 진입, 인자 해석, Lua 콜백, 원래 게임 함수의 반환을 나눠 관찰해야 했다.

추적 로그를 넣은 경계

일시적으로 UE4SS_TRACE_HOOKS=1일 때만 stderr에 기록하는 코드를 넣었다. 핵심은 로그의 양보다 같은 호출이 어느 단계까지 갔는지를 비교할 수 있게 만드는 것이었다.

위치 기록한 내용
UFunction 훅 진입 함수 이름, FFrame, Code, Locals, 인자 개수
인자별 평가 전후 인자 이름, 바이트코드 위치, opcode, 평가 후 위치
FFrame::Step opcode와 실제 선택한 핸들러 주소
Lua에서 ProcessEvent 호출 대상 함수의 호출 전후
pre 콜백과 원래 함수 진입과 정상 반환 여부

추적 코드는 UFunctionStructs.cpp, FFrame.cpp, LuaUObject.cpp에 넣었다. 플레이어가 서버에 접속해 충돌을 재현했고, 같은 RIP와 fault 주소가 다시 기록됐다.

로그 검색에서도 한 번 판단을 바로잡았다. 코드가 출력한 태그는 [UE4SS-HookTrace]였는데, 컨테이너의 pal_logger.py를 거친 결과는 [UE4SS-HOOKTRACE]였다. 처음에는 대소문자를 구분해 검색하면서 추적 로그가 없다고 잘못 판단했다. 수집 파일을 대소문자 구분 없이 다시 검색하니 229개의 추적 항목이 있었다.

rg -i 'ue4ss-hooktrace|signal handler' trace-reproduced.log

이후에는 원본 출력과 수집기가 가공한 출력을 구분해 확인했다.

정상 호출과 충돌 직전 호출의 차이

같은 TrueMercy가 사용하는 두 함수의 호출 경로가 달랐다.

관찰한 호출 FFrame.Code UE4SS가 인자를 준비하는 경로
CallOnDamageDelegateAlways null 이미 준비된 인자와 property chain 사용
SlipDamage non-null 바이트코드를 평가해 훅에 전달할 인자 구성

Code가 존재하면 UE4SS는 원래 프레임의 바이트코드를 읽어 임시 인자 공간에 값을 채운다. 이후 Lua 콜백과 원래 함수를 실행한다.

프로젝트의 FFrame::Step에서 실제 dispatch를 하는 부분은 두 줄이다.

int32 B = *Code()++;
GNatives_Internal[B](Context, *this, RESULT_DECL);

첫 줄은 명령 번호 한 바이트를 읽고 위치를 이동한다. 두 번째 줄에서 선택한 핸들러가 해당 명령의 피연산자를 읽고 처리해야 한다. 함수 표가 틀리면 단순히 반환값 하나가 잘못되는 데 그치지 않고, 다음 명령을 읽을 위치까지 틀어질 수 있다.

이 차이로 서버에서 관찰한 두 호출의 동작은 설명할 수 있었다. 다만 로컬 플레이의 동일 호출을 별도로 추적한 것은 아니므로, 로컬과 서버의 모든 차이를 이 표만으로 단정하지는 않았다.

결정적인 증거는 한 바이트씩만 움직이는 위치였다

충돌 직전 SlipDamage에는 인자 4개가 있었고 전체 인자 공간은 7바이트였다. 다음은 추적 로그에서 인자 평가와 관련된 필드를 추린 것이다.

found ...SlipDamage params=4 bytes=7 original=0x6a25270

param Damage       offset=0 code=0x7cde7d32 opcode=00
Step opcode=00 handler=0xbc8cf40 code=0x7cde7d33

param ShieldIgnore offset=4 code=0x7cde7d33 opcode=00
Step opcode=00 handler=0xbc8cf40 code=0x7cde7d34

param DeadType     offset=5 code=0x7cde7d34 opcode=0e
Step opcode=0e handler=0x44661b0 code=0x7cde7d35

param ClearShield  offset=6 code=0x7cde7d35 opcode=6d
Step opcode=6d handler=0x5239330 code=0x7cde7d36

original end ...SlipDamage
signal handler: sig=11 ... rip=0x7b8074d fault=0x2806242800004c

Step 진입 로그뿐 아니라 각 param done 로그에서도 위치가 그대로였음을 확인했다. 첫 opcode=00은 EX_LocalVariable인데, 해당 명령이 읽어야 할 property 포인터를 소비하지 않은 채 다음 인자로 넘어갔다.

0xbc8cf40은 이번 바이너리에서 EX_LocalVariable에 대응해야 할 주소가 아니었다. 올바른 핸들러는 0x7b80720이었다.

원래 SlipDamage 함수의 반환 로그도 있었다. 따라서 마지막에 호출한 원래 함수가 반드시 그 내부에서 죽었다고 볼 수 없었다. 바이트코드를 읽던 호출자 쪽의 위치가 이미 손상되어, 반환 이후 해석이 이어지는 과정까지 살펴봐야 했다.

GNatives라고 찾은 주소는 .init_array였다

기존 Linux fallback은 쓰기 가능한 ELF 세그먼트를 훑으며 실행 가능한 주소가 64개 연속으로 등장하는 첫 위치를 GNatives 후보로 채택했다.

문제는 함수 포인터가 나열된 구조가 여러 종류라는 점이다. 이번에 선택된 0xbcc4448을 게임 ELF의 섹션 정보와 비교하자 .init_array의 시작 주소와 정확히 일치했다.

.init_array  INIT_ARRAY  000000000bcc4448

.init_array는 초기화 함수를 담는 배열이다. VM 명령 번호에 따라 의미가 정해지는 GNatives와 용도가 다르지만, 실행 가능한 주소가 연속된다는 조건은 만족했다.

비교 항목 잘못 채택한 배열 검증한 GNatives
주소 0xbcc4448 0xc1534a0
정체 .init_array VM opcode 함수 표
앞의 255개 항목이 실행 가능 주소인가 예 예
opcode 0x00의 대상 0xbc8cf40 0x7b80720
정수·불리언 상수 명령과 구현이 일치하는가 아니오 예

여기서 64개를 255개로 늘리기만 해도 해결되지 않는다는 점을 실제 데이터로 확인했다. 두 배열 모두 앞의 255개 항목이 실행 가능한 주소였다. 포인터의 개수와 접근 가능 여부만으로 배열의 역할을 판별할 수 없었다.

fault 주소가 바이트코드 오해석과 연결되는 이유

RIP 0x7b8074d를 게임 실행 파일에서 역어셈블하면 다음 명령이었다.

movslq 0x4c(%rbx), %rcx

이 명령은 0x7b80720에서 시작하는 로컬 변수 명령 처리 경로 안에 있었다. 앞부분은 바이트코드에서 8바이트를 읽어 property 포인터로 사용하고 있었다.

관찰된 fault 주소 0x2806242800004c는 잘못 해석한 포인터 0x28062428000000에 필드 오프셋 0x4c를 더한 값과 일치한다.

추적 로그, 섹션 정보, 핸들러 역어셈블을 함께 보면 실패 과정은 다음과 같이 연결된다.

.init_array를 GNatives로 채택
    ↓
UE4SS가 바이트코드 인자를 평가할 때 엉뚱한 함수 호출
    ↓
피연산자를 읽지 못해 Code 위치가 잘못 남음
    ↓
SlipDamage 반환 후 호출자가 피연산자 중간에서 해석 재개
    ↓
잘못된 FProperty 포인터를 역참조 → SIGSEGV

TrueMercy의 Lua 연산을 바꾸거나 오류를 잡아 넘기는 것으로 해결할 문제가 아니었다. 이번 수정에서는 모드의 main.lua를 변경하지 않았다.

수정: 함수 주소의 모양과 opcode의 의미를 함께 확인

UE4SSProgram.cpp의 Linux GNatives fallback을 다음 조건으로 바꿨다.

  1. 탐색 범위를 메인 게임 ELF의 세그먼트로 제한한다.
  2. 정해진 opcode 위치의 핸들러가 예상한 상수 저장 명령과 일치하는지 검사한다.
  3. Unreal::EX_Max에 해당하는 255개 항목이 모두 게임의 실행 가능한 세그먼트를 가리키는지 검사한다.
  4. 모든 후보를 확인한 뒤 검증된 후보가 정확히 하나일 때만 사용한다.

이번 x86_64 SysV 코드에서 비교한 네 가지 핸들러는 다음과 같다. rdx는 결과 저장 위치인 RESULT_DECL을 전달받는다.

Opcode 의미 확인한 바이트 동작
0x25 / EX_IntZero 정수 0 c7 02 00 00 00 00 c3 [rdx]에 32비트 0 저장 후 반환
0x26 / EX_IntOne 정수 1 c7 02 01 00 00 00 c3 [rdx]에 32비트 1 저장 후 반환
0x27 / EX_True 참 c6 02 01 c3 [rdx]에 1바이트 1 저장 후 반환
0x28 / EX_False 거짓 c6 02 00 c3 [rdx]에 1바이트 0 저장 후 반환

핵심 판단은 아래와 같다. 전체 스캔 루프와 선언은 생략했다.

constexpr size_t ENTRY_COUNT = Unreal::EX_Max;

if (!matches_handler(entries[Unreal::EX_IntZero], int_zero) ||
    !matches_handler(entries[Unreal::EX_IntOne], int_one) ||
    !matches_handler(entries[Unreal::EX_True], bool_true) ||
    !matches_handler(entries[Unreal::EX_False], bool_false))
{
    continue;
}

if (!std::all_of(entries, entries + ENTRY_COUNT, is_executable))
{
    continue;
}

found_addr = seg.start + offset;
++candidates;

matches_handler는 비교할 바이트 전체가 실행 가능한 세그먼트 범위 안에 있는지도 검사한다. 후보가 여러 개라고 첫 번째를 선택하지 않는다.

여기서 255는 '대략 256개쯤 있을 것'이라는 추측 대신 프로젝트의 EX_Max == 0xFF를 사용한 결과다. 또한 모든 항목의 의미를 증명한 것은 아니다. 전체 포인터 범위와, 위치가 정해진 네 가지 상수 opcode의 구체적인 구현을 함께 검사한 것이다.

export된 GNatives 심볼을 찾는 기존 경로는 유지했고, 이번에 잘못된 배열을 선택하던 fallback을 수정했다. 바이트 패턴은 해당 플랫폼과 컴파일 결과에 의존한다. 향후 게임 빌드에서 핸들러 구현이 바뀌면 검증에 실패할 수 있다.

표를 검증하지 못했을 때의 동작도 정했다

탐색 실패 상태에서 바이트코드 인자를 Lua 콜백에 넘기면 여전히 위험하다. UFunctionStructs.cpp에는 다음 조건을 추가했다.

if (TheStack.Code() && !GNatives_Internal)
{
    // 실제 구현에서는 경고를 최초 한 번 기록한다.
    Iterator->second.GetOriginalFuncPtr()(Context, TheStack, RESULT_DECL);
    return;
}

바이트코드가 있는 호출인데 GNatives를 확보하지 못했다면, 훅 콜백을 생략하고 원래 프레임을 원래 게임 함수에 넘긴다. 이미 준비된 인자를 사용하는 compiled-in 경로는 기존 처리를 유지한다.

이 경우의 경고는 다음과 같다.

Skipping native hook callbacks for bytecode calls: GNatives is unavailable.

이 경고가 나온 상태를 모드 기능까지 정상인 상태로 취급할 수는 없다. 특히 해당 바이트코드 호출의 Lua 훅 효과는 적용되지 않는다. 이번 검증 서버에서는 표를 정상적으로 찾아 이 우회 경로를 사용하지 않았다.

실제 플레이에서 고장 났던 경로를 다시 실행

수정 후 추적 빌드를 배치하고 플레이어가 접속해 같은 종류의 전투를 다시 수행했다. 시작 로그에는 다음 검증 결과가 나왔다.

GNatives validated at 0xc1534a0 (255 entries; integer and boolean opcode handlers verified).

로그뿐 아니라 실행 중인 프로세스의 GNatives_Internal 값도 읽어 0xc1534a0인지 확인했다.

수집한 플레이 로그에서는 SlipDamage의 원래 함수가 14회 정상 반환했고, UE4SS 시그널 진단은 없었다. TrueMercy는 화상 피해 대상의 HP가 1일 때 해당 tick의 피해를 33에서 0으로 줄였다. 아래는 런타임 객체 식별자를 생략한 메시지다.

[TrueMercy] held ... at 1 HP - burn from ...,
tick 33 -> 0 (hp was 1)

플레이어가 추가 테스트 후 정상 동작을 확인했다. 따라서 이번에는 서버 기동이나 훅 등록에 그치지 않고, 문제가 발생했던 지속 피해 경로와 모드 효과까지 확인할 수 있었다.

다만 로그에서 직접 확인한 효과는 화상 피해의 HP 1 유지다. 독 피해만을 분리한 검증이나 장시간·다인원 부하 테스트를 완료했다고 확대해서 기록하지는 않는다.

추적 코드를 제거한 최종 빌드로 한 번 더 검증

플레이 테스트가 끝난 뒤에는 진단에 쓰던 변경을 정리했다.

  • FFrame.cpp와 LuaUObject.cpp의 임시 추적 코드를 제거했다.
  • UFunctionStructs.cpp에는 GNatives 부재 시의 보호 조건을 남겼다.
  • UE4SS_TRACE_HOOKS, BOX64_SHOWBT가 들어간 임시 Compose 파일을 사용하지 않도록 했다.
  • TrueMercy의 DebugLogging을 원래 값인 false로 돌렸다.

정리 중에는 파일 복원의 수정 시각도 확인해야 했다. 백업에서 원래 시각까지 보존해 복사하면 Ninja가 소스를 새로 컴파일하지 않아, 최종 라이브러리에 예전 추적 코드가 남을 수 있었다. 해당 소스의 수정 시각을 갱신해 다시 빌드했고, 최종 바이너리에 [UE4SS-HookTrace] 문자열이 없는 것까지 확인했다.

빌드는 앞선 x86_64 구성에서 진행했다.

cmake --build build_linux_Dev_gcc --target UE4SS --parallel 2

컨테이너를 내린 상태에서 새 libUE4SS.so를 임시 파일로 복사하고 rename으로 교체했다. 빌드 산출물과 배포 파일의 SHA-256도 일치했다.

영구 Compose override에는 기존 preload 설정을 유지했다.

services:
  palworld:
    environment:
      BOX64_LD_PRELOAD: /palworld/libUE4SS.so:libpthread.so.0

그 뒤 임시 진단 파일 없이 일반 docker compose up -d로 기동했다.

최종 일반 실행의 확인 항목 결과
GNatives 0xc1534a0, 255개 항목 및 상수 핸들러 검증
활성 Lua 모드 진입 스크립트 9개 모두 완료
REST /v1/api/info HTTP 200
REST /v1/api/metrics HTTP 200
컨테이너 재시작 0회
수집 로그의 preload 오류·시그널 진단 0건
임시 훅 추적 로그 없음

게임플레이 검증은 추적 빌드에서 수행했고, 추적을 제거한 빌드에서는 일반 기동과 모드 로드·API 응답을 재확인했다. 이 두 검증 범위를 구분해 기록했다.

마지막에는 서버를 내리고 프로젝트 컨테이너가 남지 않은 것을 확인했다.

cd /usr/local/src/docker/palworld
docker compose down --timeout 30
docker compose ps -aq

검증 자료는 작업 디렉터리의 build_linux_Dev_gcc/validation/truemercy/에 보관했다. 충돌 직전 추적, 두 함수 표의 비교, 플레이 로그, 최종 일반 실행 로그와 result.json이 포함된다.

다음 업데이트 때 확인할 것

이번 실패는 함수 주소가 실행 가능한 범위에 있다는 검사만으로는 그 함수의 역할을 보장할 수 없음을 보여 줬다. 초기화 함수 배열도 같은 조건을 만족했고, 잘못 선택한 주소는 서버 기동이 끝난 뒤 실제 바이트코드 훅에서 문제를 일으켰다.

게임 업데이트 후에는 GNatives validated at ... 표식과 모드 진입 로그를 확인하고, 모드가 사용하는 실제 게임 행동까지 다시 시험해야 한다. 탐색 실패 경고가 있다면 기동 성공과 모드 효과 적용을 따로 판단해야 한다.

이제 이 서버에서는 올바른 함수 표를 선택한 상태로 TrueMercy의 화상 피해 보호 동작까지 확인했다. 최종 배포에는 수정한 탐색·보호 코드와 Box64 preload 설정이 남아 있으며, 테스트용 추적 설정은 제거했다.

연재 글

  1. 1편 — ARM64 포팅 시도와 게임 아키텍처 확인
  2. 2편 — x86_64 크로스 컴파일과 Docker preload
  3. 3편 — file_open 이후 초기화 segfault 추적
  4. 4편 — Box64 콜백 슬롯 수정과 실행 검증
  5. 5편 — TrueMercy segfault와 GNatives 함수 표 검증 (현재 글)