2026-10-25
ARM 서버에서 Palworld UE4SS 실행하기 ④ — Box64 콜백 슬롯 고갈 수정과 Docker 실행 검증
dl_iterate_phdr의 서로 다른 콜백이 Box64의 브리지 슬롯을 소진했다. 공통 dispatcher로 최소 재현 테스트를 139에서 0으로 바꾸고, ELF 범위 계산 수정과 Lua·REST 검증까지 마친 기록이다.
게임 없이 같은 크래시를 재현할 수 있었다
headless x86_64 UE4SS가 엔진 스캔을 진행하던 중 다음 경고와 함께 segfault가 발생했다.
Warning, no more slot for elfloader dl_iterate_phdr callback
테스트 환경은 ARM64 Docker 컨테이너의 Box64 v0.4.3이며, 바이너리가 보고한 소스 리비전은 e694f2ca8이었다. 이 글에서 설명하는 슬롯 제한은 해당 버전을 기준으로 한다.
게임을 반복해서 부팅하는 대신 dl_iterate_phdr만 호출하는 작은 x86_64 프로그램을 만들었다. 서로 다른 32개의 콜백 주소로 전체 ELF 모듈을 열거하게 하자 같은 경고가 나왔고 종료 코드는 139였다. 공통 dispatcher를 적용한 뒤에는 동일한 환경에서 0으로 종료했다.
이 비교가 수정 방향을 결정하는 가장 명확한 증거였다.
콜백 횟수보다 서로 다른 함수 주소가 문제였다
UE4SS는 엔진 함수나 데이터를 찾기 위해 로드된 ELF 모듈을 여러 곳에서 살펴본다. 기존 코드는 스캐너마다 별도의 콜백이나 람다를 dl_iterate_phdr에 전달했다.
Box64에서는 x86_64 코드와 호스트의 네이티브 라이브러리 사이에 콜백 연결이 필요하다. 확인한 버전의 elfloader.c에는 이 열거 콜백용 래퍼가 5개 있었다. 같은 함수 주소는 재사용하고, 새로운 주소에는 비어 있는 슬롯을 배정한다. 자리가 없으면 경고를 출력하고 null을 반환하는데, 그 뒤 네이티브 dl_iterate_phdr 호출로 이어졌다.
즉, 같은 콜백을 여러 번 호출하는 것과 서로 다른 콜백을 계속 등록하는 것은 이 구현에서 다른 상황이었다. 이번 UE4SS의 사용 패턴은 후자였고, 최소 재현 테스트에서도 실패했다.
Box64에 전달하는 콜백 주소를 하나로 고정
수정은 각 스캐너의 처리 로직을 없애는 대신, 네이티브 경계를 통과하는 함수만 하나로 모으는 방식으로 했다.
수정 전
스캐너 A의 콜백 ─┐
스캐너 B의 콜백 ─┼─ Box64의 여러 콜백 슬롯
스캐너 C의 콜백 ─┘
수정 후
스캐너 A/B/C
│ 콜백과 사용자 데이터를 문맥에 저장
▼
공통 dispatch_module ── Box64에 같은 콜백 주소 전달
│
└─ 문맥에 저장한 원래 콜백 호출
Helpers/LinuxModules.hpp와 LinuxModules.cpp를 추가했다. 아래는 Linux 구현의 핵심을 한 블록으로 합친 코드다.
#include <link.h>
namespace RC
{
using ElfModuleCallback = int (*)(dl_phdr_info*, size_t, void*);
namespace
{
struct Enumeration
{
ElfModuleCallback callback;
void* data;
};
int dispatch_module(dl_phdr_info* info, size_t size, void* opaque)
{
auto& enumeration = *static_cast<Enumeration*>(opaque);
return enumeration.callback(info, size, enumeration.data);
}
}
int for_each_elf_module(ElfModuleCallback callback, void* data)
{
Enumeration enumeration{callback, data};
return dl_iterate_phdr(dispatch_module, &enumeration);
}
}
호출부에서는 직접 dl_iterate_phdr(callback, data)를 부르던 자리를 RC::for_each_elf_module(callback, data)로 바꿨다. 공통 함수는 한 .cpp에 두어, 각 호출부마다 별도의 dispatcher가 생기는 구조를 피했다.
dl_iterate_phdr의 콜백은 해당 열거 호출 중에 동기적으로 실행되므로 문맥을 스택에 둘 수 있었다. 중첩 호출이나 다른 스레드의 열거도 각각 별도 문맥을 갖는다. 원래 콜백의 반환값도 그대로 전달해 조기 종료 동작을 유지했다.
이 변경은 UE4SS의 모듈 열거가 사용하는 브리지 주소를 하나로 줄인다. 다른 라이브러리들이 사용하는 Box64 전체 콜백 슬롯의 제한까지 없애는 것은 아니다.
최소 재현 테스트에서 놓치기 쉬웠던 조건
단순히 콜백을 여러 번 실행하는 테스트만으로는 부족했다. 해당 Box64 구현은 먼저 에뮬레이션 대상 모듈을 열거한 뒤 네이티브 모듈 열거로 넘어간다. 앞부분에서 콜백이 조기 종료하면 문제가 생기는 네이티브 브리지를 사용하지 않을 수 있다.
그래서 tests/linux/elf_enumeration.cpp에는 다음 검사를 넣었다.
- 서로 다른 콜백 32개로 모듈 전체를 순회한다.
- 사용자 데이터가 올바른 콜백으로 전달되는지 확인한다.
- 별도 호출에서 조기 종료 반환값을 확인한다.
- 콜백 안에서 다시 모듈을 열거한다.
- 두 스레드에서 열거를 수행한다.
추가한 테스트와 Helpers 소스가 있는 작업트리에서의 빌드 명령은 다음과 같다.
x86_64-linux-gnu-g++ -std=c++20 -O2 -pthread \
-I deps/first/Helpers/include \
tests/linux/elf_enumeration.cpp \
deps/first/Helpers/src/LinuxModules.cpp \
-ldl -o /tmp/elf_enumeration
서버와 같은 Box64 환경에서 실행했다. 실제 검증은 동일한 Docker 이미지의 일회용 컨테이너에 이 파일을 bind mount하는 방식이었다.
# 게임이 아닌 독립 재현 프로그램. 이 버전에서는 실패를 의도한 비교다.
box64 /tmp/elf_enumeration --direct
# 공통 dispatcher 경로
box64 /tmp/elf_enumeration
| 실행 경로 | 결과 | 종료 코드 |
|---|---|---|
| 서로 다른 콜백을 직접 전달 | 슬롯 경고 후 SIGSEGV | 139 |
| 공통 dispatcher 사용 | ELF enumeration PASS | 0 |
테스트를 서버와 같은 Box64 버전에서 실행한 것이 중요했다. 네이티브 x86_64 실행만으로는 이번 브리지 제한을 재현할 수 없기 때문이다.
함께 바로잡은 ELF 모듈 범위
초기화 코드의 모듈 범위 계산에도 수정이 필요했다. 메인 실행 파일을 찾을 때 빈 모듈 이름뿐 아니라 /proc/self/exe와 일치하는 이름도 허용하도록 했다. 또한 ET_EXEC의 load bias가 0이라고 해서 모듈을 찾지 못한 것으로 처리하지 않도록 했다.
수정한 계산은 다음과 같다.
first_load = PT_LOAD들의 p_vaddr 최솟값
last_end = PT_LOAD들의 (p_vaddr + p_memsz) 최댓값
base = dlpi_addr + first_load
size = last_end - first_load
이번 바이너리의 로그에는 다음 범위가 기록됐다.
MainExe module: base=0x200000, size=202484128
이전 fallback처럼 /proc/self/maps의 첫 행만 이용해 이미지 일부를 전체로 취급하는 문제를 피했다. 단, 이 span 안의 모든 바이트가 읽을 수 있는 메모리라는 뜻은 아니다. PT_LOAD 사이에는 매핑되지 않은 간격이 있을 수 있으므로 vtable sweep은 읽을 수 있는 매핑들을 따로 순회하도록 유지했다.
콜백 슬롯 고갈과 모듈 범위 계산은 서로 다른 문제다. 전자는 독립 테스트의 segfault로 재현했고, 후자는 엔진 스캐너가 다루는 주소 범위의 정확성을 위해 수정했다.
실제 게임에서 Lua와 게임 스레드 확인
최소 재현 테스트가 통과한 뒤 빌드한 .so를 서버에 배치했다. 최종 파일은 x86_64였고, 빌드 산출물과 배치 파일의 SHA-256이 같았다.
게임에서는 임시 Box64Smoke 모드를 사용해 단계별로 진행을 확인했다. 다음 표의 표식은 실제 로그에서 모두 확인됐다.
| 로그 표식 | 확인한 동작 |
|---|---|
| LUA_STARTED | Lua 모드 진입 |
| LUA_ERROR_HANDLED | 의도한 Lua 오류를 pcall로 처리 |
| ASYNC_DISPATCH_OK | 지연 실행 콜백 |
| GAME_THREAD_REFLECTION_OK | 게임 스레드에서 Engine.Actor 조회 |
| GAME_THREAD_ALIVE_30S | 30초 뒤 게임 스레드 콜백 재실행 |
reflection 검사는 단순히 메시지만 찍는 대신 실제 객체를 찾도록 했다.
ExecuteInGameThread(function()
local actorClass = StaticFindObject("/Script/Engine.Actor")
assert(actorClass and actorClass:IsValid(),
"Engine.Actor was not resolved")
print("[Box64Smoke] GAME_THREAD_REFLECTION_OK "
.. actorClass:GetFullName() .. "\n")
end)
로그에서 반환 이름은 Class /Script/Engine.Actor였다. GUObjectArray는 250,280개 요소로 해석됐고 기존 TrueMercy 모드의 로드 메시지도 확인했다. 다만 TrueMercy의 실제 전투 효과를 플레이어가 접속해 검증한 것은 아니다.
진단 옵션을 제거한 일반 Compose 실행도 확인
임시 모드와 진단용 환경변수를 제거한 뒤에는 일반 설정으로 다시 실행했다.
cd /usr/local/src/docker/palworld
docker compose up -d
영구 override에는 다음 preload 설정을 남겼다.
services:
palworld:
environment:
BOX64_LD_PRELOAD: /palworld/libUE4SS.so:libpthread.so.0
일반 실행의 관찰 결과는 다음과 같다.
| 항목 | 관찰 결과 |
|---|---|
| Palworld 버전 | v1.0.5.102999 |
| REST /v1/api/info | HTTP 200 |
| REST /v1/api/metrics | HTTP 200 |
| 서버 FPS 표본 | 59 |
| 프레임 시간 표본 | 약 16.81ms |
| metrics 관찰 시 uptime | 70초 |
| 접속 플레이어 | 0명 |
| 컨테이너 재시작 횟수 | 0 |
| 수집 로그의 preload·콜백 슬롯 오류 | 0건 |
| 수집 로그의 UE4SS 시그널 진단 | 0건 |
이는 초기화, 모드 로드, Lua 오류 처리, 비동기·게임 스레드 실행과 짧은 서버 구동을 확인한 결과다. 59 FPS는 무접속 상태의 한 표본이며, 장시간 안정성이나 여러 플레이어의 부하 테스트 결과로 해석할 수는 없다.
또 기존 검증 로직은 UGameViewportClient::Tick의 시그니처가 맞지 않는다고 판단해 해당 후킹을 비활성화했다. 이 보호 동작을 우회하지 않았다. 게임 스레드 실행은 EngineTick 경로에서 확인했으며, 모든 훅의 동작을 검증했다고 주장할 범위는 아니다.
마지막 검증 단계는 compose down
로그와 결과를 저장한 뒤 요청한 대로 서버를 내렸다.
docker compose down --timeout 30
docker compose ps -aq
컨테이너와 Compose 네트워크가 제거됐고, 프로젝트 컨테이너가 남지 않은 것을 확인했다. 진단용 모드는 제거했으며 최종 x86_64 라이브러리, GUI를 끈 설정, Box64 preload override는 남겨 두었다.
이번 작업은 ARM64 라이브러리를 만드는 데서 시작했지만, 실제로 필요한 수정은 x86_64 빌드와 Box64 경계에 있었다. 아키텍처 확인, 빌드, 로드, 초기화, 실제 콜백 실행을 나눠 검증하면서 각 단계의 실패를 구별할 수 있었다.
연재 글
- 1편 — ARM64 포팅 시도와 게임 아키텍처 확인
- 2편 — x86_64 크로스 컴파일과 Docker preload
- 3편 — file_open 이후 초기화 segfault 추적
- 4편 — Box64 콜백 슬롯 수정과 실행 검증 (현재 글)