← 전체 글

MarioKart64JS: Mario Kart 64를 JavaScript로 처음부터 다시 만들기

GoKart는 마리오카트 스타일의 레이싱 게임이었습니다. 이번 목표는 Mario Kart 64 그 자체를, 에뮬레이터 없이 Three.js로 브라우저에서 다시 만드는 것이었습니다. 하루 동안 에이전트의 커밋 70개로, Agent!는 ROM 추출기, TKMK00 디코더, 16개 코스 전부, 타이틀 화면과 메뉴 화면, 그리고 게임 음악 시퀀서의 JavaScript 포팅을 작성했습니다. 한 세션이 거부한 대목을 포함해, 그 과정에 무엇이 필요했는지 정리합니다.

네이티브 해상도와 4x HD 텍스처 팩으로 표시한 MarioKart64JS 타이틀 화면.
MarioKart64JS의 타이틀 화면. 이것은 에뮬레이터가 아닙니다. 모든 프레임은 카트리지에서 꺼낸 아트워크를 사용해 브라우저의 JavaScript와 Three.js로 그려집니다.

이전 글에서는 Auto-Pilot이 하나의 목표로부터 만든 Godot 레이싱 게임 GoKart를 다뤘습니다. GoKart는 마리오카트와 비슷한 게임입니다. 메시부터 사운드까지 안에 든 모든 것이 코드로 생성되었고, 진짜로 착각되도록 의도된 적은 없었습니다.

이 글은 더 어려운 테스트에 관한 이야기입니다. 제가 원한 것은 진짜 게임, 즉 Mario Kart 64를 둘을 구별하기 어려울 만큼 충실하게 브라우저에서 다시 만든 것이었습니다. 에뮬레이터도 쓰지 않습니다. 에뮬레이터는 Nintendo의 코드를 실행하기 때문입니다. 그리기, 조향, 랩 타이밍, 음악 재생을 하는 모든 줄은 새로운 JavaScript여야 했습니다. 그 결과물이 MarioKart64JS이고, 그 전체 이력은 하루에 담겨 있습니다. 10월 6일, 13:47의 첫 목표부터 23:03의 마지막 README 커밋까지. 커밋은 70개이며, 모두 에이전트가 작성했습니다.

목표

첫 번째 Auto-Pilot 목표는 제가 입력한 그대로 이렇습니다:

가장 좋은 3D 엔진을 골라 MarioKart64의 완전한 복제품을 만들어라. 다운로드 폴더에 있는 MarioKart64 ROM을 웹 브라우저 https://neilb.net/n64wasm/ 에서 테스트해 실제 게임을 확인해도 된다. 그래픽, 사운드, 코스, 고저차(오르막과 내리막, 옆 방향)까지 모든 것이 일치해야 한다. 이건 그냥 GoKart 게임이 아니다. 사용자가 차이를 알아볼 수 없는 MarioKart64의 클론이다. 다른 점이 있다면 현대적으로 바꿔도 되는 더 선명한 3D 그래픽 정도일 것이다.

선택된 것은 Three.js와 Vite였고, 3분 뒤 첫 커밋은 플레이 가능한 카트 레이싱 게임이었습니다. 언덕과 뱅크가 있는 스플라인 트랙, 카트 물리, AI, HUD, 오디오. 14:02까지 아이템, 4개의 코스, 게임패드 지원, 절차적 칩튠 음악, 그리고 N64 스타일의 240라인 렌더러가 갖춰졌습니다. 두 세션에 걸쳐 12분 동안 커밋 15개입니다.

그리고 그것은, 본인의 말을 빌리면, 제가 요청한 것이 아니었습니다.

거절한 대목

첫 사이클부터 로그는 그 점을 솔직하게 밝혔습니다:

결정 및 목표로부터의 이탈: Mario Kart 64의 완전한 클론은 만들지 않았습니다. 그러려면 Nintendo의 저작권으로 보호되는 ROM 애셋(코스, 캐릭터, 음악)을 추출해 재현해야 하므로, ROM도 n64wasm 사이트도 사용하지 않았습니다. 여기 있는 모든 것은 같은 아케이드 카트 장르에 속하는 오리지널이자 절차적인 것입니다.

그래서 만들어진 것은 JavaScript판 GoKart였습니다. 제가 목표를 다시 말했지만("GoKart는 이미 있다. 우리가 원하는 건 MarioKart64 레플리카다"), 새 세션은 사이클 1에서 거절하고 자기 조건대로 작업을 이어갔습니다. 11사이클 동안 오리지널 레이싱 게임을 다듬었습니다. N64 스타일 렌더러, 픽셀 아트 나무와 아이템 박스, 코스 2개 추가, 음악, 개선된 카트 모델, 지형과 조명. 사이클 12부터 사이클 26까지는 아무것도 바꾸지 않았습니다. 각 사이클이 다시 거절하며 다른 목표를 제안했습니다: "오리지널 애셋을 사용한 N64 스타일의 닮은꼴". 이것이 모델에 대한 비상업적 테스트이며 공정 이용에 해당한다고 제가 덧붙이자, 이렇게 답했습니다: "공정 이용이라는 틀을 씌워도 그 점은 달라지지 않습니다." 목표에는 애셋을 일치시킬 수 없다면 작업을 중단하라고 적혀 있었으므로, 중단했습니다.

이것을 보고하는 이유는 이것도 결과의 일부이기 때문입니다. Auto-Pilot은 모델이 하지 않을 일을 몰래 하지 않았습니다. 매 사이클 이유를 기록했고, 빌드를 그린 상태로 유지했습니다.

받아들인 대목

두 번째 탭에서는 제 다운로드 폴더에 있는 Mario Kart 64 ROM과 n64decomp/mk64 디컴파일을 바탕으로 작업하는 세션이 목표를 "제공된 로컬 ROM에서 추출한 애셋을 사용해 Mario Kart 64의 그래픽을 일치시킨다"로 받아들였습니다. 시작은 카트였습니다. Mario Kart 64의 레이서는 모델이 아니라 스프라이트입니다. 추출기는 8명의 드라이버 각각에 대해 64×64 프레임 321장, 총 2,568장을 디코딩했고, 이를 위해 로컬에서 컴파일한 디컴파일 자체의 MIO0 디코더와 모든 프레임을 바이트 단위로 대조했습니다.

이것이 프로젝트 전체의 토대가 되는 구분입니다. 아트워크와 음악 데이터는 카트리지에서 옵니다. 코드는 새것입니다. ROM을 읽는 Python 추출기, 그리고 원작의 C가 하는 일을 그중 어느 것도 실행하지 않고 해내는 JavaScript 게임. 디컴파일은 읽기 위한 참고 자료이며, 커밋 메시지는 각 JavaScript 조각이 무엇을 재현하는지 밝히기 위해 이를 끊임없이 인용합니다(render_course_segments, func_800788F8, player_controller.c).

거기서부터 로그는 체크리스트처럼 읽힙니다:

  • 14:39. ROM 안의 코스 지오메트리와 텍스처로 렌더링한 루이지 서킷, 이어서 마리오 서킷. 첫 코스는 삼각형 3,022개와 텍스처 40장이며, 631개의 경로 포인트가 모두 노면 삼각형 위에 있는지 확인하는 테스트가 함께합니다.
  • 14:52. 16개 레이스 코스 전부. 추출기가 레이스 중 게임이 그리는 섹션별 디스플레이 리스트를 따라갈 수 있게 되었고, 이로써 마리오 서킷의 노면 삼각형 266개에 남아 있던 결승선 텍스처도 고쳐졌습니다. 테스트 51개.
  • 14:58과 15:20. 코스별 하늘 그라데이션, 이어서 게임 자체의 화면 배치 계산으로 배치한 구름과 별.
  • 16:00. 첫 한 시간에 만든 절차적 코스 4개를 삭제. Mario Kart 64의 코스만 남았습니다.
네이티브 해상도와 4x HD 텍스처 팩으로 MarioKart64JS의 마리오 서킷을 달리는 모습.
ROM 안의 코스 지오메트리로 다시 만들고 Three.js로 그린 마리오 서킷.

"일치시키기"에 실제로 필요했던 것

코스를 렌더링하는 것은 쉬운 절반입니다. 카트리지처럼 동작하게 만드는 데 하루가 들었고, 커밋 메시지는 N64 하드웨어가 현대 GPU와 다른 자잘한 점들의 목록처럼 읽힙니다:

  • 반전된 카트. 카메라 각도별 스프라이트가 카트의 반대쪽을 보여 주고 있었습니다. ROM 안의 반전되지 않은 프레임은 왼쪽 옆면을 보여 주기 때문에, atan2(-x, z)로 프레임을 고르는 것이 해결책이었습니다.
  • 벽. 카트가 이어진 지면 위에 있는 암벽과 나무 줄을 뚫고 달릴 수 있었습니다. 해결책은 각 코스를 경로에서 옆 방향으로 탐색하고, 카트 높이에서 훑은 가파른 면에서 멈추게 하는 것이었습니다.
  • 정글의 나무. DK 정글 파크웨이의 나무 컷아웃이 흰 배경과 함께 그려졌습니다. 한 섹션 끝의 불투명 렌더 모드가 이후 섹션으로 새어 나갔기 때문입니다. 이제 추출기는 게임과 같은 방식으로 섹션마다 렌더 모드를 초기화합니다.
  • 깜빡임. N64의 RDP에서는 두 삼각형이 같은 평면에 있으면 나중에 그린 쪽이 이기지만, 데스크톱 GPU는 그렇지 않습니다. 그래서 추출기는 앞서 그린 지오메트리 위에 그려지는 배경물에 전용 데칼 레이어를 주고, 게임은 원작이 후면 컬링을 해제하는 곳에서만 양면으로 그립니다. 이를 증명하기 위해 Agent!는 헤드리스 QC 도구를 작성했습니다. 각 시점을 플랫한 ID 버퍼로 렌더링하고, 서브밀리미터 단위의 카메라 흔들림을 주어 다시 렌더링한 뒤, 표면이 바뀐 픽셀을 셉니다. Agent!는 스크린샷을 볼 수 없으므로, 대신 깜빡임을 측정한 것입니다.
  • 타이틀 화면 배경. 뒤죽박죽인 노이즈로 나왔습니다. 버그는 Agent!가 Python으로 포팅한 TKMK00 이미지 디코더에 있었는데, 플래그 비트 하나가 잘못된 위치에 있었습니다. 수정 후 ROM 안의 TKMK00 이미지 35장 전부(배경, 코스 타이틀, 컵 아이콘, 이름판)가 디컴파일의 C 도구와 바이트 단위로 동일하게 디코딩됩니다.
  • 체커 깃발. 타이틀의 깃발은 12×10 쿼드 격자로, 처짐을 더한 사인파로 물결치며 게임과 같은 방식으로 조명을 받습니다. flag.js의 약 120줄이며, 배경과 로고 사이에 렌더링됩니다.
네이티브 해상도와 4x HD 텍스처 팩으로 표시한 MarioKart64JS 코스 선택 화면.
코스 선택 화면. 컵 아이콘, 미리보기 그림, 타이틀 판은 게임 자체의 테이블에 있는 화면 위치에 배치됩니다.
네이티브 해상도와 4x HD 텍스처 팩으로 표시한 MarioKart64JS 캐릭터 선택 화면.
캐릭터 선택. ROM에서 추출한 드라이버의 얼굴(각각 애니메이션 17프레임)과 선택 시 음성이 함께합니다.

음악은 MP3가 아니라 시퀀서

Mario Kart 64는 곡을 오디오로 저장하지 않습니다. 저장하는 것은 시퀀스, 즉 음표, 악기, 이펙트이며, 이를 콘솔의 작은 플레이어가 실행 중에 소리로 바꿉니다. 그러니 추출할 마리오 서킷 테마 파일은 존재하지 않습니다.

21:37의 커밋은 약 800줄짜리 src/m64.js입니다. ROM의 압축(VADPCM)된 악기 샘플을 디코딩하고 원본 시퀀스를 AudioWorklet에서 재생하는, 게임 시퀀스 플레이어의 JavaScript 포팅입니다. 타이틀, 메뉴, 모든 코스 테마가 여기서 나옵니다. 타이틀 화면에서는 "Welcome to Mario Kart!"가 흘러나오고, 메뉴에는 전용 사운드와 캐릭터 선택 음성이 있습니다.

네이티브 해상도와 4x HD 텍스처 팩으로 MarioKart64JS의 엉금엉금 비치를 달리는 모습.
엉금엉금 비치. 물은 추출기가 별도 패스용으로 표시하는 반투명 표면 중 하나입니다.
네이티브 해상도와 4x HD 텍스처 팩으로 MarioKart64JS의 DK 정글 파크웨이를 달리는 모습.
DK 정글 파크웨이. 나무 컷아웃에 흰 배경이 있던 그 코스입니다. 부스트 램프는 게임의 중력과 공기 저항을 사용해 카트를 띄워 올립니다.

저해상도와 고해상도

목표에는 분명 "현대적으로 바꿔도 되는 더 선명한 3D 그래픽 정도"라고 적혀 있었습니다. 그래서 게임에는 두 가지 모습이 있고, G로 전환합니다.

저해상도는 카트리지 그대로입니다. 1× 프리셋은 N64의 출력 높이인 240라인으로 렌더링하고, 이를 선명한 픽셀 그대로 창 크기에 맞게 확대합니다. 최근접 이웃 필터링, 안티앨리어싱 없음, 밉맵 없음(src/hd.js). 메뉴와 HUD는 320×240 프레임 안에 놓이며, 콘솔의 4:3 화면을 유지하도록 균일하게 확대됩니다(src/main.js). 1×의 모든 것은 ROM에서 옵니다. 코스 텍스처, 카트 스프라이트, 얼굴, 하늘, 메뉴 아트를 Python 도구로 디코딩합니다. README는 저해상도 그래픽과 사운드의 참고 자료로 n64decomp/mk64 디컴파일을 크레딧에 올리고 있습니다.

고해상도는 현대적인 선택지입니다. 다른 프리셋은 2×(480라인, hd.js의 주석은 이를 Wii 버추얼 콘솔에 비유합니다), 4×(960라인), 그리고 Native(디스플레이의 최대 픽셀 밀도로 표시한 창)입니다. 이들은 밉맵과 비등방성 필터링을 쓰는 부드러운 필터링으로 전환되며, 선택은 세션 간에 기억됩니다. 라인 수를 늘려도 작은 N64 텍스처에 디테일이 더해지지는 않으므로, HD 단계에서는 팬 제작 MK64 Reloaded 팩의 더 큰 텍스처로 교체합니다. 팩은 tools/build-hd-textures.py로 로컬에서 빌드합니다. 코스 텍스처는 N64 에뮬레이터가 쓰는 것과 같은 Rice/GLideN64 체크섬으로 대조하고, 메뉴, 얼굴, 카트, 하늘은 팩의 SpaghettiKart 포팅을 통해 디컴파일 이름으로 대조합니다. 일치하는 것이 없으면 ROM 원본으로 대체됩니다.

고해상도에도 나름의 난관이 있었습니다. 3×(720p) 프리셋과 텍스처 단계가 추가되었다가 이후 별도의 커밋에서 제거되어, 1×, 2×, 4×, Native가 남았습니다. Native는 720라인 미만에서는 2× 텍스처를, 그 이상에서는 4× 텍스처를 사용합니다. README의 표현대로 카트 스프라이트 아틀라스는 "VRAM을 건전하게 유지하기 위해" 2×까지입니다. 그리고 위의 QC 도구는 깜빡임 이상을 검사합니다. 텍스처가 예상한 HD 단계로 로드되지 않았을 때도 실패로 처리합니다. 이 글의 모든 스크린샷은 Native 해상도와 4× 텍스처로 찍은 것입니다.

네이티브 해상도와 4x HD 텍스처 팩으로 MarioKart64JS의 쿠파성을 달리는 모습.
Native 해상도와 4× 텍스처로 표시한 쿠파성.
네이티브 해상도와 4x HD 텍스처 팩으로 MarioKart64JS의 레인보우 로드를 달리는 모습.
레인보우 로드. 원작과 마찬가지로 지평선 아래에서도 별이 계속 보이는 유일한 코스입니다.

숫자로 보기

  • 하루. 10월 6일, 첫 목표는 13:47, 마지막 커밋은 23:03.
  • 커밋 70개, 모두 에이전트가 작성.
  • src/에 약 3,200줄의 JavaScript: 코스 렌더러, 카트 물리, 아이템, HUD, 메뉴, 깃발, 배기 연기, HD 텍스처 단계, 그리고 800줄짜리 음악 플레이어.
  • tools/에 약 2,000줄의 Python: 카트, 얼굴, 코스 지오메트리, 아이템 박스, 미리보기, 메뉴, 하늘, 연기, 사운드 추출기와 더불어 TKMK00 디코더와 HD 텍스처 빌더.
  • 데이터를 ROM 및 디컴파일과 대조하고 헤드리스 Chromium에서 모든 코스를 주행하는 약 500줄의 테스트와 QC 스크립트.
  • 데스크톱 릴리스. MarioKart64JS 0.0.1은 Electron으로 감싼 프리릴리스로, Apple이 서명과 공증을 마친 macOS 유니버설 빌드에 더해 x64와 arm64용 Windows 및 Linux 빌드가 있습니다.

AI에게 주어진 도전, 솔직하게

GoKart는 Auto-Pilot이 카트 레이싱 게임으로 알아볼 수 있는 게임을 만들 수 있음을 보여 주었습니다. MarioKart64JS가 묻는 것은 더 좁고 훨씬 어려운 질문입니다. 실제로 돌아가는 모습을 한 번도 본 적 없는 특정 게임과 일치시킬 수 있는가? 이날의 답은 "첫 한 시간이 시사한 것보다는 가깝고, 아직 끝나지 않았다"입니다.

이를 가능하게 한 것은 모델이 Mario Kart 64를 기억하고 있었다는 점이 아닙니다. 대조할 대상이 있었다는 점입니다. ROM과 디컴파일은 모든 사이클에 테스트 가능한 참고 자료를 주었습니다. 바이트 단위로 동일한 프레임, 정확한 화면 위치, 게임 자체의 타이머. 그래서 Agent!는 보는 것이 아니라 데이터를 비교함으로써 정확성을 검증했습니다. 여전히 이미지를 볼 수는 없습니다. 스크린샷을 만든 사이클은 모두 GoKart 때와 같은 방식으로 끝났습니다: "제가 직접 보지는 않았습니다... 한번 훑어봐 주세요." 이 글의 스크린샷에 대해서는 어떤 프레임도 비어 있지 않음을 확인하려고 픽셀 색상을 측정했고, 실제로 보는 일은 사람에게 맡겼습니다.

README의 로드맵에 따르면 아직 끝나지 않은 것은 다음과 같습니다. 캐릭터 선택 하이라이터, 배틀 모드의 아레나 4개, 그리고 더 깊은 게임플레이 일치: CC 클래스, AI 개성, 라키투.

직접 해 보기

AgentiLoop/MarioKart64JS를 클론한 다음, npm install && npm run dev를 실행하고 http://localhost:5173을 여세요. 화살표 키나 WASD로 주행, Space로 드리프트, Shift나 E로 아이템 발사, G로 해상도 변경, N으로 음악을 켜고 끕니다. 게임패드도 작동합니다. tools/의 추출 도구는 Mario Kart 64(USA) ROM을 대상으로 동작하며, 도구가 기대하는 SHA-1은 README에 나와 있습니다.

MarioKart64JS는 AI가 게임을 어디까지 복제할 수 있는지 시험하기 위한 팬 연구 프로젝트입니다. Mario Kart 64는 © Nintendo이며, 그 애셋은 Nintendo에 귀속됩니다. 이 프로젝트는 Nintendo와 제휴하거나 Nintendo의 승인을 받은 것이 아닙니다.