2026-10-25

ARM 서버에서 Palworld UE4SS 실행하기 ③ — file_open 다음의 segfault를 추적한 방법

마지막 로그가 file_open이라고 파일 열기가 실패한 것은 아니었다. stderr 경계 로그, GUI 비활성화, 로더 실험으로 초기화 단계를 나누고 Box64의 콜백 경고까지 도달한 과정을 기록한다.

마지막 로그는 마지막으로 실행된 코드와 다를 수 있다

x86_64 UE4SS를 Box64에 로드한 뒤 처음 보인 증상은 단순했다. UE4SS.log를 여는 메시지가 나오고 곧바로 segfault가 발생했다. 그래서 첫 의심은 파일 열기, 경로 변환, 권한 문제였다.

그런데 실제 파일에는 이미 Console created라는 첫 로그가 기록되어 있었다. 파일 크기는 35바이트였다. 적어도 파일을 열고 첫 메시지를 쓰는 경로 일부는 실행된 것이다.

로그가 멈춘 위치와 실패한 위치를 구분하려면, 큰 함수의 시작에만 로그를 찍는 것으로는 부족했다. 이번에는 초기화 과정의 작은 경계마다 진입과 반환을 기록했다.

파일 로거를 조사하는 로그는 stderr로

문제가 의심되는 파일 로거를 통해 진단 메시지를 남기면 같은 경로를 다시 호출하게 된다. 그래서 추적용 메시지는 fprintf(stderr, ...)로 보냈다. Docker가 수집하는 stderr에서 파일 출력과 별도로 진행 상황을 볼 수 있었다.

새로 세분화한 지점은 다음과 같다.

경계 확인하려는 내용
파일 로거 등록 전후 출력 장치 등록이 반환되는가
콘솔 생성 전후 콘솔 초기화가 끝나는가
첫 로그 호출 전후 호출자가 출력 함수에서 돌아오는가
UTF-16 → UTF-8 변환 전후 문자열 변환에서 멈추는가
파일 쓰기 전후 실제 write 경로가 끝나는가
콘솔 출력 전후 다른 출력 장치에서 멈추는가
버전·시간대 처리 전후 첫 출력 다음의 초기화가 진행되는가

파일 쓰기에 넣은 추적 코드는 다음과 같은 형태였다.

static std::atomic<unsigned> writes{0};
const char* debug = std::getenv("UE4SS_DEBUG");
const bool trace =
    debug && std::atoi(debug) > 0 && writes.fetch_add(1) < 8;

if (trace) {
    fprintf(stderr, "[UE4SS] File write: converting %zu UTF-16 code units...\n",
            string_to_write.size());
}
auto utf8_str = to_string(StringType(string_to_write));
if (trace) {
    fprintf(stderr, "[UE4SS] File write: writing %zu UTF-8 bytes...\n",
            utf8_str.size());
}
write_to_file(*this, utf8_str.c_str(), utf8_str.size());
if (trace) {
    fprintf(stderr, "[UE4SS] File write: complete.\n");
}

콘솔 수신부에도 같은 식으로 추적을 추가했다. 반복 출력이 많은 경로이므로 디버그 환경변수가 있을 때 처음 8회만 기록하도록 제한했다.

또 UE4SS_DEBUG를 명시했다면 INI 설정을 읽은 뒤에도 그 값이 우선하도록 바꿨다. 초기에는 디버그 로그가 나오다가 설정 파일을 읽은 뒤 사라지는 상황을 피하기 위해서다.

파일 출력은 어디까지 성공했나

수정한 빌드에서는 다음 순서가 확인됐다. 아래는 시간과 다른 출력들을 생략한 실제 로그다.

[UE4SS] Constructor: writing first log message...
[UE4SS] LinuxFile::open_file: /palworld/UE4SS.log
[UE4SS] LinuxFile::open_file: fd=54
[UE4SS] File write: converting 35 UTF-16 code units...
[UE4SS] File write: writing 35 UTF-8 bytes...
[UE4SS] File write: complete.
[UE4SS] Console write: entering (16 UTF-16 code units)...
[UE4SS] Console write: complete.
[UE4SS] Constructor: first log write returned; formatting version...

파일 디스크립터 값 자체보다 중요한 것은 complete와 호출자의 returned가 모두 있다는 점이다. 파일 쓰기뿐 아니라 콘솔 출력도 끝나고 호출 지점으로 돌아왔다. 수정 빌드에서 확인한 이 경로와 기존 파일의 첫 로그를 함께 보면, 'file_open이 마지막으로 보였으니 파일 열기가 실패했다'는 가설은 유지하기 어려웠다.

한 번의 segfault 뒤에 여러 문제가 있었다

초기 바이너리의 크래시 위치는 libstdc++.so.6 + 0xb82d0 부근이었다. BOX64_DYNAREC=0으로 인터프리터 실행을 시험했을 때도 같은 증상이 나타났다. 이것만으로 Box64 전체를 배제할 수는 없지만, 동적 재컴파일러에만 생기는 문제라는 설명은 약해졌다.

게임과 플러그인의 C++ 런타임 심볼 간섭도 조사했다. 프로젝트에는 Palworld가 노출하는 __cxa_throw 등의 심볼과 UE4SS의 C++ 런타임이 충돌할 수 있다는 기존 기록이 있었다. 저장소의 Linux 상태 문서

진단 목적으로 RTLD_DEEPBIND를 쓰는 작은 로더를 만들어 실행 경로를 비교했다. 이 실험에서 얻은 결과는 두 가지였다.

  1. 즉시 심볼을 해결하는 로드에서는 ps_scan 누락이 드러났다.
  2. 다른 로드 조건에서는 GUI 초기화의 RC::GUI::DebuggingGUI::setup에서 null 접근이 드러났다.

이 로더는 최종 해결책으로 배치하지 않았다. 심볼 결합 방식을 바꾸면 다른 C++ 런타임 동작까지 영향을 받을 수 있어서, 관찰 결과를 문제 분리에 사용하는 데 그쳤다. 최종 배포에는 별도 deepbind 로더가 없다.

전용 서버에 필요하지 않은 GUI 경로를 제거

설정 파일에는 GUI 콘솔이 켜져 있었다.

GuiConsoleEnabled = 1
GuiConsoleVisible = 1

두 값을 0으로 바꾼 기존 라이브러리는 최초 실패 지점을 지나 엔진 스캔까지 진행했다. 새 빌드도 UE4SS_GUI_ENABLED=OFF, UE4SS_INPUT_ENABLED=OFF로 만들었고, 운영 INI에는 다음 값을 유지했다.

GuiConsoleEnabled = 0
GuiConsoleVisible = 0

이 결과로 headless 운영 경로를 확보했다. 다만 최초 libstdc++ null 접근의 모든 내부 원인을 하나의 원인으로 증명한 것은 아니다. GUI 비활성화의 관찰 결과, 로더 실험에서 확인한 GUI 크래시, 뒤에서 재현한 콜백 슬롯 고갈은 구분해서 기록해야 한다.

로그를 너무 많이 남기면 실행 특성도 바뀐다

한때 BOX64_ROLLING_LOG=32도 켰다. 이 환경에서는 스캐너의 빈번한 memcmp 호출 추적 비용 때문에 진행이 매우 느려졌다. 멈춘 것처럼 보였지만 옵션을 제거하자 스캔이 다시 빠르게 진행됐다.

그래서 필요한 초기화 경계 로그는 유지하고, 호출량이 큰 추적 옵션은 줄였다. 로그의 양을 늘리는 것보다 '어느 호출이 반환되지 않았는가'를 구별할 수 있게 만드는 편이 유용했다.

그다음 재현에서는 실패 직전에 다음 경고가 나타났다.

Warning, no more slot for elfloader dl_iterate_phdr callback

이어 UE4SS 시그널 로그에는 sig=11, fault=0x0이 찍혔다. 이제 파일이나 GUI에 관한 추측 대신, ELF 모듈 열거 콜백이라는 구체적인 경계에서 조사할 수 있었다.

가설을 바꾸게 만든 증거

관찰 그 관찰로 좁힐 수 있었던 범위
기존 UE4SS.log에 첫 메시지 존재 파일 열기 자체의 실패라는 단정은 부정확
인터프리터 모드에서도 같은 초기 크래시 dynarec 전용 문제로 단정할 수 없음
GUI를 끈 기존 바이너리가 스캔까지 진행 headless 실행 경로를 구성할 근거 확보
즉시 로드에서 ps_scan 누락 확인 Rust 스캐너 링크 수정 필요
콜백 슬롯 경고 직후 SIGSEGV dl_iterate_phdr 경계를 최소 재현할 대상 확보

다음 편에서는 마지막 항목을 게임 없이 재현하고, UE4SS의 콜백 주소를 하나로 모아 수정한 과정을 다룬다. 파일이 로드된다는 확인을 넘어 실제 Lua 게임 스레드 콜백과 Docker의 정상 응답까지 확인한 결과도 함께 정리한다.

연재 글

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