mGBA 멀티플레이어 구성에 대한 실험, 연구
mGBA 를 브라우저에서 동작시키자
어렸을 때 유일하게, 기기 수명을 다할 때까지 사용했던 핸드헬드 게임기 Game Boy Advanced
그리고 50가지가 넘은 미니 게임과 젤다의 전설 2개가 들어있던 카트리지.
그 카트리지에는 미니 게임이기는 하지만 2인 플레이를 제공하는 몇몇 게임들이 있었다.
친구와 링크 케이블을 연결하고 오토바이 미니 게임을 같이 했던 것이 기억에 남는다. (근데 게임 이름이 기억이 안난다..)
mGBA 는 오픈소스 게임보이 에뮬레이터이다.
mGBA에는 이미 링크 케이블 기능이 존재한다.
문제는 그 기능이 원래 데스크톱 환경을 기준으로 설계되었다는 점이다.
그걸 WebAssembly 환경으로 가져오면 과연 어디까지 동작할까?
이건 그렇게 시작된 실험이었다.
보통은 당신이 생각하는 것이 세상에 이미 있습니다
wasm-mgba 같은 프로젝트는 이미 있었다.
하지만 브라우저 플랫폼이라는 이유로 일부 기능이 빠져있었다(라고 당시에는 생각했다. 나중에 알게 되었지만 이것은 단순히 빠진 기능이라기보다는 브라우저 환경에서 구현 난이도가 매우 높은 영역에 가까웠다.)
특히 멀티플레이 구현은 어지간한 다른 에뮬레이터들도 지원해주지 않는다.
그래서 대단히 이상하게 보였다.
왜지?
왜 이미 mGBA 에서 지원하는 기능을 안된다고 뺀거지?
내부망에서 해도 안됩니다
가상 링크 케이블 구현은 약간, 시리얼 통신과 비슷하다. 즉각적인 송/수신을 목표로 한다. 그래서 같은 공유기의 유선 연결 상태일지언정 아주 미세하게라도 핑이 튀면 치명적이다. 에뮬레이터는 그 짧은 찰나의 지연을 감지하고 상대방 연결이 끊어졌다고 판단, 연결 끊김 상태가 된다.
그래서 대부분의 에뮬레이터는 멀티플레이 구현을 포기한다. 메탈슬러그처럼 애초에 다수 플레이어가 같은 화면을 보며 게임을 진행하는 경우라면 스팀의 리모트 플레이처럼 화면은 공유하고 입력을 별도로 넣는 방식을 쓰고 있었다.
난 그래서 더 의아했다.
요즘 기기들은 에뮬레이터 기준으로 봤을 때 이미 충분히 사양이 높고, 에뮬레이터를 한번에 4대쯤 돌려도 괜찮아 보였다. 그러니까, 호스트가 4대의 에뮬레이터를 돌리고 참여자가 리모트 플레이를 하면 되는게 아닌가..?
AI 수혜자
이론상 가능해보이는 구조였고, 사양이 충분히 좋으니까 브라우저에서 4개를 돌리는 것도 가능해보인다. 그럼 이번 토이 프로젝트는 너인거지.
mGBA 는 C 가 77%에 육박하는 압도적인 저수준 언어 프로젝트다.
그리고 나는 고수준 언어를 쓰는 개발자다. 이런 깊고 난해한 언어. 난 읽을 수 없다. C언어는 프로그래밍 처음 배울 때 눈대중으로 본 것이 전부였으므로 프로젝트를 시작할 때는 사실상 C를 전혀 읽을 수 없었다.
그래도 알고 있었다.
모든 언어의 목표는 동작하는 구조 구현이고 나는 머리 속에 명확한 구조가 있는 상태였다.
그러니까, 이거 그대로 AI 에게 요청하면 된다.
아무리 그래도 AI 가 만능은 아니지
시작이 아주 수월했다.
wasm 으로 mGBA 를 묶고, 에뮬레이터 표시를 위한 canvas 를 준비, 멀티플레이 창에 해당하는 에뮬레이터를 각기 다른 캔버스에 연결하고 멀티플레이 구현. 특정 canvas 에 focus 되면 해당 에뮬레이터에 입력하는 식으로 분리.
뭐야, 시작과 동시에 끝까지 와버렸는걸?
이라고 생각했으나 에뮬레이터간 기기 탐색 단계만 들어가면 엄청난 프레임 저하에 시달렸고 게임을 시작하려고 하면 즉시 연결이 끊어지기 일쑤였다.
뭐지?…
나는 직감적으로 깨달았다. 에뮬레이터 저 깊은 곳까지 파고내려가야한다는 것을.
때마침 gemini-cli 가 한창 베타 테스트를 하고 있기에 나는 코드를 긁어서 AI 한테 전달하는 노가다를 멈추고 빈번히 gemini-cli 에게 포크된 프로젝트에 대해 물어봤다. 무료 버전 한도를 채울 때까지 몇일간 구조 파악 및 가설에 투자했다.
저수준 이야기
(난 이번 토이 프로젝트에서 이 부분을 정말 좋아한다)
멀티플레이의 중심인 가상 링크 구현은 락스텝(LockStep)이라는 절차와 긴밀하게 연결되어있었고 각각의 기기는 코디네이터라는 녀석이 중앙에서 관리해주고 있었다.
그리고 저 락스텝이라는 절차는 고수준 개발만 했던 내 시선에서는 정말 아름답게 동작하고 있었다.
고수준 언어에서의 업데이트 최소 단위는 보통 화면 주사율에 맞춰져있다. 대체로 1/60 안에 연산이 끝날 수 있어야하며 (실제로 거의 대부분은 이 안에 끝남) 1프레임마다 연산이 된다는 것을 전제한다.
저수준 언어에서의 최소 단위는 사이클(cycle) 또는 스텝(Step)으로, mGBA 기준으로 1프레임에 280896 스텝을 밟는다. 락스텝(LockStep)이라 함은 두 에뮬레이터의 진행 수준을 맞추는 절차이다.
락스텝은 큰 그림에서 보면 이렇게 동작한다.
- 마스터 기기(호스트)가 슬레이브 기기(참여자)에게 ack(반응 정도로 해석합시다)을 요청하고 마스터 기기는 잠든다(sleep)
- 각각의 슬레이브 기기는 데이터를 레지스터에 작성하고 (실 기기였다면 마스터에게 데이터를 전송하여 기록하는 행위일 듯) 마스터에게 ack 을 발송, 스스로 잠든다.
- 마지막으로 작업하고 ack을 발송하는 슬레이브는 마스터를 깨운다(wake)
- 마스터가 일어나서 추합된 데이터를 검토하고 자신에게 적용, 모든 슬레이브에게 적용시키기 위해 ack 을 요청하고 자신은 잠든다
- 데이터 내용은 다르나 위의 ack 그리고 잠들고 깨기 행동을 반복
여기서 sleep 상태가 된 기기는 스텝을 밟지 않으며 대기하고 wake 상태가 되면 다시 스텝을 밟는 식인데, 이 행동을 하는 궁극의 목표는 기기간 상태 동기화였다. 그러니까, “너네들 이 상태까지 다 왔지? 다시 한 걸음 이동한다?“를 계속해서 반복하는 것이었다.
이 과정을 1프레임 안에 여러차례 밟으면서 다음 프레임이 되었을 때 어떤 화면을 보여줄지에 대한 합의가 끝나면 모든 기기가 같은 시간에 같은 결과물 화면을 렌더링해주는 마법이 일어나는 것이었다.
브라우저는 생각보다 더 얇은 플랫폼입니다
브라우저는 기본적으로 싱글스레드로 동작하며 나는 이 기본에 충실한 채로 완성해내고 싶었다. 이때는 그래도 될 정도로 가볍다고 생각했으니까.
근데 네트워크 동작방식을 뜯어보니 저런 식이었고 내 머리는 더더욱 이걸 어떻게든 싱글스레드로 해결하고 싶은 마음으로 가득했다. AI 에 기대고 있으니 좀 더 적극적으로 미쳐있었다.
어차피 같은 상태를 원하는 거라면 시작시점부터 같이 시작하자
ROM 파일을 첨부하자마자 4대의 에뮬레이터를 한번에 켜는 것만으로도 상황이 개선되긴 했다. 기기 탐색을 넘어 인게임 멀티플레이에 진입할 수 있었으니까. 싱글스레드 구성이었으니 에뮬레이터를 순회하면서 스텝을 진행시키면 순서가 틀릴리도 없었다. 가설은 완벽했다.
Bad BIOS Load16
라고 생각한 나를 비웃듯, 무심하게 오류 로그를 남긴 채 기기 간 연결이 끊어졌다. 심지어 이 오류는 재현할 수는 있지만 원인을 찾을 수 없는 오류였고 나는 생각을 고쳐먹기로 했다. (그냥 2분~40분 정도 인게임 멀티플레이를 하다 보면 발생하는 오류로 아직도 이에 대한 원인은 가설 뿐이고 아직도 못고쳤습니다, 오류 발생 시간조차 랜덤하죠..?)
로그로 최대한 쫓아가도 오류 직전까지 3~7사이클 차이가 날 정도로 대단히 동일한 상태로 진행을 한다. 내가 보기엔 락스텝이 아닌 다른 이유 (싱글스레드로 멀티스레드 구성을 운용한게 제일 유력한 후보) 로 메모리 참조 오류를 낸 후 슬레이브 기기가 지연에 빠지고 마스터 기기가 슬레이브를 검토할 때쯤 천 스텝이상 차이나면서 연결이 끊어지는 것으로 예상하고 있다.
이 글을 작성하는 시점에도 해당 오류는 완전히 해결하지 못했다. 지금도 여러 가설을 검토 중이다.
그래, 락스텝 절차를 싱글스레드로 처리하기에 역부족인 것 같다
실제로 2인 멀티플레이 때보다 4인 멀티플레이 때 지연이 더 심각했으므로 이게 직관적인 결론이었다. 이후 기기 부하 관리를 위해 솔로 플레이시에는 싱글스레드용 구성을 쓰도록 구성 분기가 생기게 된다 (대단히 가볍습니다)
원래도 멀티스레드였는걸
현대의 브라우저는 Shared Array Buffer 라는 영리한 우회책을 이용해 멀티스레드를 구현할 수 있다. mGBA 는 원래도 멀티스레드 프로그램이었다. 이제 정말 문제가 없어보였다.
멀티스레드인 상태에서는 인게임 멀티플레이시 BIOS 오류가 발생하지는 않았다. 대신 플레이 못할 정도로 지연이 발생했다.
웃긴건 싱글스레드일 때보다 훨씬 느렸으며, 2인 멀티든 4인 멀티든 동일한 속도로 느렸다. 대신 연결이 전혀 끊어지지 않았으므로 내 머리는 여러 생각들로 복잡해진다.
‘이 구조가 정확도를 잡는 대신 프레임을 포기한게 아닐까?’
‘브라우저라는 플랫폼 특성상 멀티스레드에도 한계가 있는건가?’
‘사람들이 이 짓거리를 안하는 이유가 있었던걸까?’
…
싱글스레드 때부터 내가 각 에뮬레이터의 스텝을 관리하던 방식은 시간 기준진행이었다. mGBA 는 1프레임에 280896스텝을 진행하니까, 각 에뮬레이터가 280896스텝을 밟으면 다음 프레임 단계가 될 때까지 기다리는 식이다. 나는 이 단계가 멀티스레드가 되면서 부하를 일으킨다고 가정하고 다른 진행 방식을 찾아다녔다.
오디오 버퍼 기반 진행
새로운 방식을 찾았고, 테스트하지 않을 이유가 없었다. AI 를 진득하게 괴롭혔다.
꽤 정석적인 진행 시스템이었는걸?
의외로, 오디오 버퍼 기반 진행은 정석적인 에뮬레이터 진행 방식이었다. AI 는 왜 나에게 이걸 먼저 안알려줬는가…?ㅠ (원래 AI 는 사용자가 원하는 쪽으로 방향을 제시해줍니다. 코딩에는 정답이 없기도 하구요)
그리고 실험 결과 역시 놀라웠다. 첫 실험부터 정속도 진행은 아니었지만 인게임 멀티플레이시 게임 진행이 가속(!)으로 진행되고 있던 것이다. 연결 끊김은 전혀 없었고 게임이 가속되고 있다는 말은, 구조는 정확하고 이전의 프레임 저하 문제는 오로지 내가 구성한 구조에 따른 이슈였으며 속도만 정속도로 맞출 수 있다면 브라우저에서 mGBA 로 멀티플레이 구현이 가능하다는 이야기였다.
나는 AI 를 집요하게 괴롭혀서 오디오 버퍼를 기준으로 스텝을 진행하게 했고 이제 속도는 꽤나 정속으로 진행하게 된다.
지포스 나우는 되던데 나는 왜
최종 구조에서 WebRTC 로 참여자가 리모트 플레이가 가능하게 하는 구조는 간단하다. 호스트는 참여자와 핸드셰이크가 끝나면 캔버스 화면과 AudioContext 를 참여자에게 보낸다. 참여자는 호스트에게 사용자 입력만 보낸다. 호스트는 참여자가 몇번째 에뮬레이터를 사용할 것인지를 추적하여 해당 에뮬레이터에게 입력을 전달한다.
방향성은 맞게 진행되었으나 궁극적인 문제가 있었다. 호스트도 참여자도 모두 브라우저 위에서 돌아가는 중이라는 것.
브라우저에서는 WebRTC 설정이 제한되며 그 제한된 설정 조차도 설정을 “권고”하는 것이지 “강제”하는게 아니다. 그러니까, 브라우저가 “권고”를 넘어선 행동이 필요하다고 판단하면 “권고” 따위는 무시할 수 있다는 것이다.
그 결과 나는 사용자 화면 전송 지연과 화면 해상도 및 FPS 등 최종 화면의 질 사이에서 저울질을 해야했고 리모트 플레이에서는 화면 지연이 화면의 질보다 중요하다는 판단으로 해상도와 FPS를 조금 포기하고 입력 지연을 체감상 0.2초 수준으로 낮췄다. 어차피 WebRTC 로 전송할 때 비디오 인코딩-디코딩 과정으로 화면이 일그러지는 부분이 있기에 꽤 합리적인 선택이었다고 생각한다.
오디오 문제에 대해
최종 구조에서 WebRTC 를 통해 참여자에게 오디오를 보내려면 AudioContext 구조가 좋다. 하지만 AudioContext 는 오디오 풀링 및 예약 재생 방식이므로 오디오 버퍼를 잘못 관리하면 소리가 깨져들리고, 지연재생되기 일쑤였다. (그리고 이 글을 작성하는 현재 버전(v0.2.17)에서도 잘못 관리했기에 아직도 그럽니다; 이런 이유로 솔로플레이는 싱글스레드로 분기를 나눴어요)
그래서 실험적으로 AudioWorklet 을 도입하려고 하는데 이녀석은 오디오를 직접 따라가는 대신 4개 에뮬에 돌리기에는 상대적으로 부하가 더 생기고 AudioContext 보다 심하게 지연이 발생한다. (체감상 0.2~1초 수준)
사실 이론만 따지면 AudioWorklet 이 압도적으로 올바른 선택이나 게임에서 오디오가 0.5초만 차이나도 생각 이상으로 거슬렸기에, AudioContext 에서 노이즈가 발생하지 않는 구성을 찾고 있습니다. 물론, AudioWorklet 도 간간히 만져보구요.
마무리
이 프로젝트는 개인적인 호기심과 실제 구현 가능성을 확인하기 위해 시작한 프로젝트입니다. 진행하는 과정에서 비록 특정 부분에 한정된 시각이긴 하지만 많은 구조적 이해, 영감을 얻었습니다.
특히 이 프로젝트를 시작할 때 저는 mGBA 코드를 거의 읽지 못했습니다. 하지만 구조를 이해하고 가설을 세우는 능력만 있다면 AI는 언어 장벽을 크게 낮춰주더군요. 이번 실험은 브라우저 멀티플레이 연구이기도 했지만 동시에 “AI와 함께 저수준 프로젝트를 탐색하는 실험”이기도 합니다.
여러분은 이 주소에서 WebAssembly 기반 mGBA 동작 실험 결과를 확인할 수 있습니다.
멀티플레이 구조를 실험해보고 싶다면 프로젝트 저장소에 포함된 relaywss 예제를 참고할 수 있습니다.
본 실험은 오픈소스 에뮬레이터의 동작 연구 및 WebAssembly 환경에서의 동기화 구조 분석을 목적으로 진행되었습니다.
에뮬레이터 자체는 범용 소프트웨어이며 특정 게임 데이터는 포함하지 않습니다.
테스트는 적법하게 확보한 소프트웨어 또는 공개된 홈브루 허브 프로그램을 기준으로 수행하였습니다.
읽어주셔서 감사합니다.
기능 테스트 참여해주신 분들
Razorbacky, 달달달달달달달님, 요슈아, 디으, 금쪽이
