[모비노기] 유니티 게임 “용량 부족(메모리 부족)” 에러의 기술적 원인과 해결법 (마비노기 모바일 / Wine GetProcessMemoryInfo 버그 분석)

리눅스 데스크톱(CachyOS, Arch, Fedora, Ubuntu 등) 환경에서 Wine이나 GE-Proton을 통해 윈도우 게임을 즐기다 보면, 시스템 램(32GB/64GB)과 그래픽카드 VRAM이 넘쳐나는데도 인게임에서 “메모리 부족 / 용량 부족” 에러를 뿜으며 특정 기능(촬영 모드, 고해상도 그래픽 설정 등)이 강제 차단되는 황당한 현상을 마주할 때가 있습니다.
최근 일본에서 PC/모바일 크로스플랫폼으로 서비스 중인 마비노기 모바일(Mabinogi Mobile JP)의 경우에도, 리눅스에서 구동 시 인게임의 핵심 기능인 촬영 모드 (撮影モード)를 누르면 아래와 같은 에러가 발생하며 진입이 불가능했습니다.

「容量不足のため、撮影モードを実行できません。」
(용량 부족으로 인해 촬영 모드를 실행할 수 없습니다.)
윈도우 PC에서는 100% 정상 작동하는 기능이 왜 리눅스 Wine/Proton에서만 메모리 부족으로 차단되는지, 바이너리 리버스 엔지니어링을 통해 밝혀낸 Wine의 숨겨진 버그와 게임 파일 수정 없이 100% 순정 상태로 해결하는 방법을 공유합니다.
1. 문제 분석: 왜 32GB 램에서도 용량 부족이 뜰까?
처음 이 에러를 보면 그래픽 VRAM 부족이나 디스크 용량 부족을 의심하기 쉽습니다. 하지만 시스템 모니터링 결과는 정반대였습니다.
- 실제 하드웨어: RAM 32GB, RADEON RX 7800XT (VRAM 16GB), 여유 디스크 수백 GB
- 게임 실제 메모리 점유율: 고작 1.2 GB ~ 1.5 GB 수준
- 발생 현상: [촬영 모드] 버튼 클릭하면 용량 부족 팝업이 표시되고 촬영 모드로는 안들어감.
게임 클라이언트 내부의 판정 로직을 추적해 보았습니다.
게임 클라이언트(GameAssembly.dll)의 메모리 검사 공식
마비노기 모바일(Unity IL2CPP 기반)의 촬영 모드 진입부에는 렌더링 버퍼를 할당하기 전, 튕김 방지를 위한 메모리 예산 검사 루틴(CheckPhotoModeMemoryStatus)이 존재합니다.
가용 메모리 = (PC 시스템 메모리 예산 약 10.2 GB) - (현재 프로세스가 사용 중인 Private Bytes)
이 계산 결과로 도출된 가용 메모리가 촬영에 필요한 최소 기준치(약 500MB~1GB)보다 작으면 IsAllocatable 플래그가 거짓이 되며 즉시 NotEnoughMemory(2) 에러 팝업을 띄우도록 설계되어 있습니다.
그런데 디버깅 로그를 까보니 충격적인 숫자가 찍히고 있었습니다.
[devCAT Memory Check]
Total Memory Budget: 10,240 MB (10.2 GB)
Process Memory In Use: 25,600 MB ~ 수십 TB (비정상 값!)
Available Memory: 0 Byte (음수로 인해 0 처리)
Result: NotEnoughMemory (차단)
게임은 실제로 1.2GB밖에 안 쓰는데, 게임 엔진이 “현재 프로세스가 25GB(또는 수십 TB)를 쓰고 있다”고 완전히 잘못 인지하고 있었던 것입니다.
2. 근본 원인 규명: 유니티 엔진과 Wine kernelbase.dll의 치명적 불일치
그렇다면 왜 유니티 엔진은 프로세스 메모리 사용량을 엉뚱하게 인식했을까요?
유니티 엔진(UnityPlayer.dll)과 Wine의 윈도우 호환 API 계층을 단계별로 파고들었습니다.
① 유니티 엔진의 메모리 측정 방식
유니티 엔진은 현재 프로세스의 램 점유율(Private Bytes)을 알아내기 위해 마이크로소프트 표준 API인 GetProcessMemoryInfo를 호출합니다.
PROCESS_MEMORY_COUNTERS_EX pmcex; // 80바이트 크기의 확장 구조체
GetProcessMemoryInfo(GetCurrentProcess(), (PROCESS_MEMORY_COUNTERS*)&pmcex, sizeof(pmcex));
size_t usedRam = pmcex.PrivateUsage; // +0x48 오프셋의 전용 커밋 메모리 읽기
여기서 유니티 엔진 개발팀의 작은 습관이 하나 있습니다. 스택에 선언한 pmcex 구조체를 ZeroMemory로 0 초기화하지 않고 그대로 API에 넘긴다는 점입니다. 윈도우 순정 커널에서는 API가 모든 필드를 채워주므로 문제가 되지 않습니다.
② Wine 업스트림의 치명적인 구현 누락 (Bug)
하지만 리눅스의 Wine/Proton 라이브러리인 kernelbase.dll의 구현부는 달랐습니다.
[UnityPlayer.dll]
│ 80바이트 PROCESS_MEMORY_COUNTERS_EX 버퍼를 넘기며 호출
▼
[Wine: kernelbase.dll :: GetProcessMemoryInfo]
│ 내부 ntdll을 통해 시스템 커널로부터 VM_COUNTERS_EX를 정상 수신함
│ 유니티 버퍼로 데이터를 복사 시작...
│ PageFaultCount, WorkingSet, Quota, PagefileUsage (72바이트) 복사 완료!
│
❌ [결함 발생]: 확장 구조체의 핵심인 PrivateUsage (+0x48 오프셋) 복사를
코드에서 완전히 누락한 채 그대로 ret (함수 종료)!
▼
[유니티 엔진의 스택 메모리]
PrivateUsage 자리에 아무것도 써지지 않음.
이전에 스택에 남아있던 쓰레기 64비트 주소값(수십 GB ~ 수백 TB)이 그대로 반환됨!
Wine 개발진이 구형 72바이트 PROCESS_MEMORY_COUNTERS 구조체 복사 루틴만 작성해 두고, 윈도우 XP SP2 이후 도입된 80바이트 PROCESS_MEMORY_COUNTERS_EX의 마지막 필드인 PrivateUsage를 복사하는 8바이트 어셈블리 명령어를 빼먹은 것입니다.
이로 인해 유니티 엔진은 스택의 쓰레기 포인터 값을 실제 램 사용량으로 오인했고, devCAT의 뺄셈 공식에 의해 가용 메모리가 즉시 0 Byte로 계산되어 팝업이 떴던 것입니다.
3. 단계별 패치 적용 가이드 (Proton / Wine)
이 패치는 유니티 엔진이 넘겨준 80바이트 버퍼의 PrivateUsage 위치에 실제 프로세스 메모리 수치를 정확하게 복사해 넣도록 17바이트의 에필로그 코드를 재배치합니다.
1단계: 패치할 kernelbase.dll 위치 확인
본인이 사용하는 Proton 또는 Wine 버전의 경로를 확인합니다.
(아래는 Steam의 GE-Proton11-11 기준 경로입니다.)
# 본인의 Proton 경로에 맞게 설정 (예시: GE-Proton11-11)
export WINE_DLL_PATH="$HOME/.local/share/Steam/compatibilitytools.d/GE-Proton11-11-x86_64/files/lib/wine/x86_64-windows/kernelbase.dll"
2단계: 파이썬 원클릭 핫픽스 스크립트 실행
터미널을 열고 아래 스크립트를 그대로 복사하여 실행합니다.
자동으로 안전 백업(.orig)을 생성한 후 PrivateUsage 누락 버그를 즉시 패치합니다.
python3 - << 'EOF'
import os, sys
dll_path = os.path.expanduser("~/.local/share/Steam/compatibilitytools.d/GE-Proton11-11-x86_64/files/lib/wine/x86_64-windows/kernelbase.dll")
backup_path = dll_path + ".orig"
# Wine kernelbase.dll GetProcessMemoryInfo 에필로그 원본 바이트
# 48 81 c4 98 00 00 00 (add $0x98, %rsp)
# 5b (pop %rbx)
# 5d (pop %rbp)
# c3 (ret)
# 0f 1f 80 00 00 00 00 (nop 7 bytes)
orig_pattern = bytes.fromhex("4881c4980000005b5dc30f1f8000000000")
# 수정 패치 바이트:
# 48 8b 45 f0 (mov -0x10(%rbp), %rax) -> ntdll의 PrivateUsage 값 로드
# 48 89 43 48 (mov %rax, 0x48(%rbx)) -> pmcex.PrivateUsage 필드에 저장!
# 48 8d 65 08 (lea 0x8(%rbp), %rsp) -> 스택 복원
# 5b (pop %rbx)
# 5d (pop %rbp)
# c3 (ret)
# 90 90 (nop nop)
patch_bytes = bytes.fromhex("488b45f048894348488d65085b5dc39090")
if not os.path.exists(dll_path):
print(f"[Error] 파일을 찾을 수 없습니다: {dll_path}")
sys.exit(1)
# 쓰기 권한 부여
os.chmod(dll_path, 0o755)
with open(dll_path, "rb") as f:
content = bytearray(f.read())
pos = content.find(orig_pattern)
if pos == -1:
if patch_bytes in content:
print("[Info] 이미 패치가 적용되어 있습니다!")
else:
print("[Error] 원본 시그니처를 찾을 수 없습니다. 이미 수정되었거나 다른 빌드입니다.")
sys.exit(0)
# 백업 생성
if not os.path.exists(backup_path):
with open(backup_path, "wb") as f:
f.write(content)
print(f"[OK] 원본 백업 완료: {backup_path}")
# 바이트 덮어쓰기
content[pos:pos+len(patch_bytes)] = patch_bytes
with open(dll_path, "wb") as f:
f.write(content)
print(f"[Success] 0x{pos:x} 오프셋에 GetProcessMemoryInfo 핫픽스 적용 완료!")
EOF
3단계: 디스어셈블리 검증 (선택 사항)
적용 후 objdump로 확인해 보면, 함수가 리턴되기 직전 0x48(%rbx)(즉 PrivateUsage) 필드를 완벽하게 채워주고 나가는 것을 확인할 수 있습니다.
objdump -d --start-address=0x17402f92e --stop-address=0x17402f948 "$WINE_DLL_PATH"
17402f92e: movdqu -0x18(%rbp),%xmm0
17402f933: movups %xmm0,0x38(%rbx)
17402f937: mov -0x10(%rbp),%rax <-- ntdll에서 PrivateUsage 로드
17402f93b: mov %rax,0x48(%rbx) <-- pmcex.PrivateUsage에 정확히 기록!
17402f93f: lea 0x8(%rbp),%rsp <-- 스택 정리 후 복귀
17402f943: pop %rbx
17402f944: pop %rbp
17402f945: ret
4. 결과 검증: 인게임 촬영 모드 정상 작동!
패치 적용 후 게임을 다시 실행하고 [촬영 모드 (撮影モード)]를 클릭해 보았습니다.
- 패치 전: 버튼 클릭 즉시
容量不足のため、撮影モードを実行できません。차단 - 패치 후: 에러 팝업 없이 4K 해상도의 피사계 심도(DOF), 광원 필터, UI 숨김 기능이 포함된 촬영 모드 스크린에 즉시 부드럽게 진입 성공!
게임 클라이언트 파일을 건드리는 것이 아니라 프로톤/와인의 파일을 패치하기 때문에 넥슨의 안티치트 탐지나 무결성 검사에 아무런 영향을 주지 않으며, 게임 업데이트가 진행되어도 유지됩니다.
5. 마무리
모비노기는 한국에서 많이 했고 더이상 할 생각은 없는데 리눅스에서 게임이 돌아가는지 마는지는 중요사항이니까.
안티그래비티 제미나이 3.8을 이용해서 해결함. 윈도우보다 리눅스에서 이런 문제가 발생했을때 해결이 쉽다. 윈도우는 애초에 이런 문제가 없겠지만…