8비트의 벽을 넘은 게임들 Part 2: 슈퍼 마리오브라더스, 레벨을 그리지 않고 서술하다
이 글은 Claude Opus 5 를 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
4,460바이트짜리 세계#
Part 1에서 확인한 제약을 다시 꺼내 놓겠습니다.
- 슈퍼 마리오브라더스 카트리지는 PRG-ROM 32KB + CHR-ROM 8KB = 40KB 입니다.
- 한 프레임 중 VRAM 을 만질 수 있는 시간은 2,273 사이클이고, 스프라이트 DMA 를 빼면 배경에 쓸 수 있는 것은 약 220바이트 입니다.
- 6502 에는 곱셈 명령이 없습니다.
이 세 줄을 지키면서 32개 스테이지가 흐르는 게임을 만들어야 합니다.
먼저 결론부터 숫자로 보겠습니다. 이 글은 doppelganger 가 공개한 슈퍼 마리오브라더스 전체 disassembly 를 근거로 씁니다. 거기서 레벨 데이터 테이블만 골라 바이트 수를 세어 봤습니다.
| 항목 | 크기 |
|---|---|
| 지형 및 오브젝트 데이터 (34개 에어리어) | 3,373 바이트 |
| 적 배치 데이터 (34개 에어리어) | 1,087 바이트 |
| 합계 | 4,460 바이트 |
| 참고: 화면 한 장의 네임테이블 + 어트리뷰트 | 1,024 바이트 |
게임 전체의 레벨 데이터가 화면 4.4장 분량입니다. PRG-ROM 32KB 중 13.6% 입니다. 나머지 86% 는 코드와 음악과 그 밖의 것들이 씁니다.
월드 1-1 하나만 떼어 보면 더 극적입니다.
| 월드 1-1 | 크기 |
|---|---|
| 지형 및 오브젝트 | 101 바이트 (헤더 2바이트 + 오브젝트 49개 + 종료 표시 1바이트) |
| 적 배치 | 30 바이트 |
| 합계 | 131 바이트 |
화면 한 장이 1,024바이트인데, 그 화면 열몇 개 길이의 스테이지 전체가 131바이트입니다. 어떻게 이게 가능한지가 이 글의 내용입니다.
1. 레벨을 그리지 않고 서술한다#
레벨을 저장하는 가장 자연스러운 방법은 타일 배열입니다. [[0,0,3,3,0],[1,1,1,1,1], ...] 같은 2차원 배열이죠. 이 방식은 편집이 쉽고 코드가 단순합니다. 그리고 터무니없이 큽니다.
마리오는 이 방식을 쓰지 않습니다. 대신 레벨을 “어디에 무엇이 있다” 는 문장의 나열 로 저장합니다. 문장 하나가 2바이트 입니다.
2바이트의 구조#
disassembly 의 파서 코드(ProcessAreaData, DecodeAreaData)를 따라가면 인코딩이 그대로 드러납니다.
바이트 0 바이트 1
┌────────┬────────┐ ┌───┬────────┬────────┐
│ X │ 행 │ │페이지│ 타입 │ 파라미터 │
│ 4비트 │ 4비트 │ │1비트│ 3비트 │ 4비트 │
└────────┴────────┘ └───┴────────┴────────┘
| 필드 | 뜻 |
|---|---|
| X (바이트0 상위 4비트) | 현재 페이지 안에서의 열 위치 (0~15) |
| 행 (바이트0 하위 4비트) | 세로 위치 (0~15). 단 12~15는 특수 용도 |
| 페이지 (바이트1 d7) | 세우면 다음 페이지로 넘어간다 |
| 타입 (바이트1 d6~d4) | 오브젝트 종류 |
| 파라미터 (바이트1 d3~d0) | 길이, 높이, 아이템 종류 등 |
한 페이지는 16열입니다. X 가 4비트뿐이라 페이지 안에서만 위치를 지정할 수 있고, 다음 페이지로 넘어갈 때 d7 을 세워 알립니다. 좌표를 절대값으로 저장하지 않고 증분으로 저장하는 것 이 첫 번째 절약입니다.
행 값이 0~11 이면 평범한 오브젝트입니다. 이때 타입 필드가 0이 아니면 “큰 오브젝트”, 0이면 “작은 오브젝트” 로 갈립니다.
| 행 | 타입 필드가 가리키는 것 |
|---|---|
| 0~11, 타입 != 0 | 수직 파이프, 벽돌 가로줄, 단단한 블록 가로줄, 코인 가로줄, 벽돌 세로줄, 블록 세로줄 |
| 0~11, 타입 == 0 | ? 블록(파워업 / 코인 / 숨김), 숨은 1UP, 아이템 벽돌(덩굴 / 스타 / 1UP), 코인 벽돌, 점프대 등 |
| 12 | 구덩이, 도르래 로프, 다리(상/중/하), 물 구덩이, ? 블록 줄 |
| 13 | 도입부 파이프, 깃대, 도끼, 사슬, 성 다리, 스크롤 잠금, 적 무한 생성 제어 |
| 14 | 에어리어 속성 변경 |
| 15 | 무한 로프, 저울 발판, 성, 계단, 출구 파이프 |
행 12~15 가 재미있습니다. 세로 위치 16칸 중 네 칸을 희생해서 “위치가 필요 없는 명령” 을 같은 스트림에 밀어 넣었습니다. 계단 하나, 성 하나, 깃대 하나가 전부 2바이트입니다. 별도의 스크립트 언어도, 별도의 테이블도 없습니다.
월드 1-1 을 직접 읽어 보기#
disassembly 에서 1-1 의 지형 데이터(L_GroundArea6)의 첫 부분은 이렇습니다.
;level 1-1
L_GroundArea6:
.db $50, $21
.db $07, $81, $47, $24, $57, $00, $63, $01, $77, $01
.db $c9, $71, $68, $f2, $e7, $73, $97, $fb, $06, $83
...
.db $fd
앞의 $50, $21 은 2바이트 헤더입니다. 파서 코드를 따라 비트를 나눠 보면 이렇게 읽힙니다.
| 바이트 | 비트 | 값 | 뜻 |
|---|---|---|---|
$50 |
d7~d6 | 1 | 게임 타이머 설정 (400초) |
$50 |
d5~d3 | 2 | 플레이어 등장 방식 |
$50 |
d2~d0 | 0 | 전경 배경물 없음 |
$21 |
d7~d6 | 0 | 에어리어 스타일 |
$21 |
d5~d4 | 2 | 배경 배경물 (언덕) |
$21 |
d3~d0 | 1 | 지형 종류 |
1-1 의 제한 시간이 400초라는 사실이 헤더 2비트에서 그대로 나옵니다.
그다음부터가 오브젝트입니다. 2바이트씩 끊어서 위의 규칙으로 해독하면 이렇게 됩니다.
| 바이트 | 페이지 | 열 | 절대 열 | 행 | 뜻 |
|---|---|---|---|---|---|
$07 $81 |
1 | 0 | 16 | 7 | ? 블록 (코인) |
$47 $24 |
1 | 4 | 20 | 7 | 벽돌 가로줄 (길이 5) |
$57 $00 |
1 | 5 | 21 | 7 | ? 블록 (파워업) |
$63 $01 |
1 | 6 | 22 | 3 | ? 블록 (코인) |
$77 $01 |
1 | 7 | 23 | 7 | ? 블록 (코인) |
$c9 $71 |
1 | 12 | 28 | 9 | 수직 파이프 (높이 2) |
$68 $f2 |
2 | 6 | 38 | 8 | 수직 파이프 (높이 3) |
$e7 $73 |
2 | 14 | 46 | 7 | 수직 파이프 (높이 4) |
$97 $fb |
3 | 9 | 57 | 7 | 수직 파이프 (높이 4, 입장 가능) |
$06 $83 |
4 | 0 | 64 | 6 | 숨은 1UP 블록 |
1-1 을 기억하는 분이라면 이 표가 그 스테이지 그대로라는 걸 알아볼 겁니다. 처음 만나는 외톨이 ? 블록, 그다음 벽돌 다섯 칸 줄과 그 안에 박힌 버섯 ? 블록, 위쪽에 떠 있는 ? 블록 하나, 그리고 점점 높아지는 파이프 네 개. 네 번째 파이프만 들어갈 수 있습니다.
이 열 줄이 20바이트입니다.
파이프의 파라미터 4비트는 다시 쪼개집니다. 파서 코드는 파이프 오브젝트에 한해 d3 을 따로 검사합니다.
LrgObj: sta $00
cmp #$70 ;수직 파이프 오브젝트인가?
bne NotWPipe
lda (AreaData),y ;맞으면 두 번째 바이트를 다시 읽어
and #%00001000 ;d3(용도 제어 비트)만 남긴다
beq NotWPipe ;d3 이 0이면 장식용 파이프
lda #$00 ;1이면 들어갈 수 있는 파이프로 바꾼다
sta $00
즉 파이프는 d3 = 입장 가능 여부, d2~d0 = 높이 로 4비트를 다시 나눠 씁니다. 비트 하나도 남기지 않습니다.
Go 로 옮기면#
이 인코딩을 Go 로 재현하면 구조가 분명해집니다.
type AreaObject struct {
Page int // 페이지 번호 (16열 단위)
Column int // 페이지 안에서의 열
Row int // 세로 위치 (0~15)
Type int // 오브젝트 종류
Param int // 길이, 높이, 아이템 종류 등
}
// 레벨 데이터 스트림을 오브젝트 목록으로 푼다.
// $fd 가 나오면 에어리어 끝이다.
func DecodeArea(data []byte) []AreaObject {
var objs []AreaObject
page := 0
for i := 0; i+1 < len(data) && data[i] != 0xfd; i += 2 {
b0, b1 := data[i], data[i+1]
if b1&0x80 != 0 { // d7: 다음 페이지로 넘어간다
page++
}
objs = append(objs, AreaObject{
Page: page,
Column: int(b0 >> 4), // 상위 4비트 = 열
Row: int(b0 & 0x0f), // 하위 4비트 = 행
Type: int(b1>>4) & 0x07,
Param: int(b1 & 0x0f),
})
}
return objs
}
디코더가 열 줄입니다. 압축 알고리즘이라고 부를 만한 것이 하나도 없습니다. 허프만도, LZ 도, RLE 조차 없습니다. 표현을 바꾼 것뿐입니다.
이게 중요한 대목입니다. 압축이라고 하면 보통 범용 압축 알고리즘을 떠올리지만, 8비트 게임기에서는 압축을 푸는 데도 시간과 RAM 이 듭니다. 마리오가 택한 것은 애초에 작게 표현하는 것 이고, 그래서 압축 해제 비용이 0입니다. 파서가 곧 렌더러입니다.
2. 땅바닥은 4비트로 충분하다#
위의 오브젝트 목록에는 이상한 점이 하나 있습니다. 땅이 없습니다.
1-1 의 지면은 스테이지 전체에 깔려 있는데, 오브젝트 목록 어디에도 “여기부터 여기까지 땅” 이라는 항목이 없습니다. 땅은 오브젝트가 아니기 때문입니다.
헤더 두 번째 바이트의 하위 4비트가 지형 종류 였습니다. 이 4비트가 가리키는 테이블이 disassembly 에 그대로 있습니다.
TerrainRenderBits:
.db %00000000, %00000000 ;천장 없음, 바닥 없음
.db %00000000, %00011000 ;천장 없음, 바닥 2줄
.db %00000001, %00011000 ;천장 1줄, 바닥 2줄
.db %00000111, %00011000 ;천장 3줄, 바닥 2줄
.db %00001111, %00011000 ;천장 4줄, 바닥 2줄
.db %11111111, %00011000 ;천장 8줄, 바닥 2줄
.db %00000001, %00011111 ;천장 1줄, 바닥 5줄
.db %00000111, %00011111 ;천장 3줄, 바닥 5줄
.db %00001111, %00011111 ;천장 4줄, 바닥 5줄
.db %10000001, %00011111 ;천장 1줄, 바닥 6줄
.db %00000001, %00000000 ;천장 1줄, 바닥 없음
.db %10001111, %00011111 ;천장 4줄, 바닥 6줄
.db %11110001, %00011111 ;천장 1줄, 바닥 9줄
.db %11111001, %00011000 ;천장 1줄, 중간 5줄, 바닥 2줄
.db %11110001, %00011000 ;천장 1줄, 중간 4줄, 바닥 2줄
.db %11111111, %00011111 ;위아래 완전히 막힘
16가지 지형 패턴이 32바이트 에 들어 있습니다. 한 항목이 2바이트 = 16비트이고, 이 비트들이 화면 세로 13칸 중 어디가 채워져 있는지를 그대로 나타냅니다.
헤더의 지형 필드가 1이면 “천장 없음, 바닥 2줄"입니다. 1-1 의 지면은 스테이지 데이터 어디에도 저장돼 있지 않습니다. 헤더의 4비트 하나가 스테이지 전체의 지면을 결정합니다.
그럼 1-1 중간중간의 구덩이는 어떻게 될까요. 그건 오브젝트입니다. 행 12 의 Hole_Empty 가 “여기서 몇 칸 동안 바닥을 지워라” 는 뜻입니다. 기본값은 공짜로 깔고, 예외만 2바이트씩 적는 것 입니다.
이 발상은 오늘날에도 그대로 통합니다. 설정 파일에서 기본값을 생략하고 다른 것만 적는 것과 같은 구조입니다. 다만 1985년에는 이게 취향의 문제가 아니라 생존의 문제였습니다.
3. 16 x 16 은 우연이 아닙니다#
이제 이 오브젝트들이 어떻게 화면의 타일이 되는지 봅니다.
파서는 오브젝트를 곧바로 8 x 8 타일로 바꾸지 않습니다. 중간에 메타타일 이라는 단계를 둡니다. disassembly 의 렌더링 코드에 형식이 주석으로 적혀 있습니다.
DrawMTLoop: stx $01
lda MetatileBuffer,x ;메타타일 번호를 가져와 상위 2비트만 남긴다
and #%11000000
sta $03 ;어트리뷰트 테이블 비트를 여기 저장
asl ;메타타일 형식은 다음과 같다:
rol ;%xx000000 - 어트리뷰트 테이블 비트
rol ;%00xxxxxx - 메타타일 번호
메타타일 1바이트의 구성입니다.
| 비트 | 뜻 |
|---|---|
| d7~d6 | 팔레트 번호 (4개 중 하나) |
| d5~d0 | 메타타일 번호 (64종) |
그리고 메타타일 하나는 8 x 8 타일 네 개, 즉 16 x 16 픽셀 입니다.
여기서 Part 1 의 내용이 연결됩니다. 패미콤에서 팔레트를 고를 수 있는 최소 단위가 16 x 16 픽셀 이었습니다. 마리오의 세계가 16 x 16 블록으로 이루어져 있는 것은 미술적 선택이 아닙니다. 하드웨어가 색을 바꿀 수 있는 최소 단위에 게임의 최소 단위를 정확히 맞춘 것 입니다.
이렇게 맞춰 놓으면 좋은 일이 생깁니다. 메타타일 번호 안에 팔레트 번호가 들어 있으므로, 배경을 그리면서 어트리뷰트 테이블이 저절로 함께 만들어집니다. 팔레트를 따로 관리할 필요가 없습니다.
계층을 정리하면 이렇습니다.
에어리어 오브젝트 (2바이트)
↓ 파서
메타타일 (1바이트 = 팔레트 2비트 + 번호 6비트)
↓ 메타타일 그래픽 테이블
CHR 타일 4개 (16 x 16 픽셀)
↓ PPU
화면
4. 구름과 수풀은 정말 같은 그림입니다#
레트로 게임 얘기에서 가장 자주 인용되는 트리비아가 “마리오의 구름과 수풀은 같은 그래픽” 이라는 것입니다. 흔히 “같은 스프라이트” 라고 잘못 전해지는데, 둘 다 배경 타일입니다. 그리고 정말로 같은지는 disassembly 를 열면 한 번에 확인됩니다.
메타타일 그래픽 테이블은 팔레트별로 네 개가 있습니다. 팔레트 0번 테이블의 앞부분입니다.
Palette0_MTiles:
.db $24, $24, $24, $24 ;빈 칸
.db $27, $27, $27, $27 ;검은 메타타일
.db $24, $24, $24, $35 ;수풀 왼쪽
.db $36, $25, $37, $25 ;수풀 가운데
.db $24, $38, $24, $24 ;수풀 오른쪽
그리고 팔레트 2번 테이블의 앞부분입니다.
Palette2_MTiles:
.db $24, $24, $24, $35 ;구름 왼쪽
.db $36, $25, $37, $25 ;구름 가운데
.db $24, $38, $24, $24 ;구름 오른쪽
.db $24, $24, $39, $24 ;구름 아래 왼쪽
.db $3a, $24, $3b, $24 ;구름 아래 가운데
.db $3c, $24, $24, $24 ;구름 아래 오른쪽
CHR 타일 번호가 한 바이트도 다르지 않습니다.
| 왼쪽 | 가운데 | 오른쪽 | |
|---|---|---|---|
| 수풀 (팔레트 0) | $24 $24 $24 $35 |
$36 $25 $37 $25 |
$24 $38 $24 $24 |
| 구름 (팔레트 2) | $24 $24 $24 $35 |
$36 $25 $37 $25 |
$24 $38 $24 $24 |
같은 그림에 다른 팔레트를 씌운 것입니다. 초록 팔레트를 씌우면 수풀이 되고, 흰색 팔레트를 씌우면 구름이 됩니다. CHR-ROM 8KB 중에서 구름 몫으로 나간 바이트는 0입니다.
이 게임에서 배경 배경물은 열두 종류뿐입니다.
BackSceneryMetatiles:
.db $80, $83, $00 ;구름 왼쪽
.db $81, $84, $00 ;구름 가운데
.db $82, $85, $00 ;구름 오른쪽
.db $02, $00, $00 ;수풀 왼쪽
.db $03, $00, $00 ;수풀 가운데
.db $04, $00, $00 ;수풀 오른쪽
.db $00, $05, $06 ;산 왼쪽
.db $07, $06, $0a ;산 가운데
.db $00, $08, $09 ;산 오른쪽
.db $4d, $00, $00 ;울타리
.db $0d, $0f, $4e ;큰 나무
.db $0e, $4e, $4e ;작은 나무
메타타일 번호 $80 은 상위 2비트가 %10 이므로 팔레트 2번, 번호 0번입니다. $02 는 팔레트 0번, 번호 2번입니다. 앞의 두 테이블에서 확인했듯 이 둘이 가리키는 CHR 타일은 동일합니다.
구름 쪽에만 “아래” 메타타일 세 개가 더 있는 이유도 보입니다. 구름은 수풀보다 세로로 한 칸 더 큽니다. 그 한 칸만 새로 그렸고, 위쪽 절반은 수풀을 그대로 씁니다.
5. 한 프레임에 한 줄씩#
Part 1 에서 계산했듯 한 프레임에 배경으로 밀어 넣을 수 있는 것은 약 220바이트입니다. 화면 한 장은 1,024바이트입니다. 그러면 스크롤은 어떻게 할까요.
답은 화면 전체를 다시 그리지 않는 것 입니다. 패미콤에는 스크롤 레지스터가 있으니 이미 그려진 것은 하드웨어가 밀어 줍니다. CPU 가 할 일은 화면 오른쪽 밖으로 새로 들어올 세로 한 줄만 미리 채워 넣는 것 뿐입니다.
disassembly 의 렌더링 코드가 이걸 그대로 합니다.
lda #$9a ;길이 바이트 26을 d7 을 세운 채로 저장
sta VRAM_Buffer2+2,y ;d7 은 주소를 32씩 증가시키라는 뜻 (세로 방향)
$9a 는 %10011010 입니다. d7 이 세로 쓰기 플래그이고, 나머지 7비트가 26 입니다. 26개 타일을 세로로 쭉 쓰라는 뜻입니다. 화면 세로가 30타일이고 위쪽 4줄은 상태바이니, 26줄이 곧 플레이 영역 전체입니다.
한 번에 26바이트입니다. 220바이트 예산 안에 여유 있게 들어갑니다.
8프레임 파이프라인#
더 흥미로운 것은 이 작업을 어떻게 나누느냐입니다. 파서는 한 프레임에 전부 하지 않고 여덟 개의 작업으로 쪼개서 여덟 프레임에 나눠 처리합니다.
AreaParserTasks:
jsr JumpEngine
.dw IncrementColumnPos
.dw RenderAreaGraphics
.dw RenderAreaGraphics
.dw AreaParserCore
.dw IncrementColumnPos
.dw RenderAreaGraphics
.dw RenderAreaGraphics
.dw AreaParserCore
한 주기가 두 번 반복되는 구조입니다.
graph LR
A["AreaParserCore<br/>메타타일 13칸 계산"] --> B["RenderAreaGraphics<br/>왼쪽 절반 26타일"]
B --> C["RenderAreaGraphics<br/>오른쪽 절반 26타일"]
C --> D["IncrementColumnPos<br/>다음 열로"]
D --> A
style A fill:#FFD700,color:#000000
style B fill:#87CEEB,color:#000000
style C fill:#87CEEB,color:#000000
style D fill:#90EE90,color:#000000
- AreaParserCore — 오브젝트 스트림을 읽어 메타타일 버퍼 13칸을 채웁니다. 세로 13칸이 메타타일 기준 화면 높이입니다.
- RenderAreaGraphics — 메타타일 13칸을 CHR 타일 26개로 펼쳐 VRAM 버퍼에 넣습니다. 메타타일이 가로 2칸이므로 왼쪽 절반과 오른쪽 절반을 두 프레임에 나눠 처리합니다.
- IncrementColumnPos — 다음 열로 넘어갑니다.
여덟 프레임에 RenderAreaGraphics 가 네 번 돕니다. 즉 8프레임에 8픽셀 폭 네 줄 = 32픽셀 을 미리 그려 둡니다. 프레임당 4픽셀입니다.
여기서 검산을 해 봅시다. 뒤에서 보겠지만 마리오의 최고 속도는 프레임당 2.5픽셀 입니다. 렌더러는 프레임당 4픽셀을 그립니다.
4 > 2.5. 마리오가 아무리 빨리 달려도 렌더러가 앞서 갑니다. 이 여유가 우연일 리 없습니다. 최고 속도를 먼저 정해 놓고 렌더링 주기를 맞췄거나, 그 반대입니다. 어느 쪽이든 게임 디자인의 숫자와 하드웨어 예산이 같은 표에서 결정됐다 는 뜻입니다.
6. 물리를 정수로 만들기#
이제 마리오가 움직이는 부분입니다. 6502 에는 부동소수점이 없습니다. 곱셈도 없습니다. 그런데 마리오의 움직임에는 관성이 있고 미끄러짐이 있고 가변 점프 높이가 있습니다.
전부 정수와 테이블로 되어 있습니다.
가로 이동: 4.4 고정소수점#
가로 속도를 다루는 코드입니다.
MoveObjectHorizontally:
lda SprObject_X_Speed,x ;현재 속도 값을 가져와
asl ;하위 니블을 상위로 옮긴다
asl
asl
asl
sta $01 ;결과를 여기 저장 (= 소수부)
lda SprObject_X_Speed,x ;다시 가져와서
lsr ;상위 니블을 하위로 옮긴다
lsr
lsr
lsr
cmp #$08 ;8 미만이면 그대로
bcc SaveXSpd
ora #%11110000 ;8 이상이면 부호 확장 (음수)
SaveXSpd: sta $00 ;결과를 여기 저장 (= 정수부)
속도 1바이트를 상위 4비트 = 정수부, 하위 4비트 = 소수부 로 나눠 씁니다. 4.4 고정소수점입니다. 상위 니블이 8 이상이면 ora #$f0 으로 부호를 확장해 음수로 만듭니다.
이 눈으로 최고 속도 테이블을 다시 보면 값이 읽힙니다.
MaxLeftXSpdData:
.db $d8, $e8, $f0
MaxRightXSpdData:
.db $28, $18, $10
.db $0c ;파이프 도입 연출용
| 값 | 정수부 | 소수부 | 픽셀/프레임 |
|---|---|---|---|
$28 |
2 | 8/16 | +2.5 |
$18 |
1 | 8/16 | +1.5 |
$10 |
1 | 0 | +1.0 |
$d8 |
$d → -3 | 8/16 | -2.5 |
$e8 |
$e → -2 | 8/16 | -1.5 |
$f0 |
$f → -1 | 0 | -1.0 |
좌우가 정확히 대칭입니다. 그리고 마리오의 최고 속도는 프레임당 2.5픽셀, 60fps 기준 초당 150픽셀입니다. 화면 가로가 256픽셀이니 화면 하나를 가로지르는 데 1.7초입니다.
Go 로 옮기면 이렇습니다.
// 4.4 고정소수점 속도를 위치에 더한다.
// pos 는 정수 픽셀 위치, frac 은 8비트 소수 누산기.
func moveHorizontally(pos, frac uint8, speed uint8) (newPos, newFrac uint8) {
intPart := speed >> 4
if intPart >= 8 {
intPart |= 0xf0 // 부호 확장
}
fracPart := speed << 4
sum := uint16(frac) + uint16(fracPart)
newFrac = uint8(sum)
carry := uint8(sum >> 8) // 소수부에서 넘어온 자리올림
newPos = pos + intPart + carry
return
}
핵심은 carry 입니다. 소수부가 넘칠 때만 정수 위치가 1픽셀 움직입니다. 곱셈도 나눗셈도 없고, 덧셈과 시프트뿐입니다.
세로 이동: 8.8 고정소수점#
세로는 더 정밀해야 합니다. 중력이 매 프레임 조금씩 누적되어야 하니까요. 그래서 속도와 소수부를 아예 별도 바이트로 둡니다.
ImposeGravity:
pha
lda SprObject_YMF_Dummy,x
clc ;소수 누산기에 이동력을 더한다
adc SprObject_Y_MoveForce,x
sta SprObject_YMF_Dummy,x
ldy #$00
lda SprObject_Y_Speed,x ;현재 수직 속도
bpl AlterYP
dey ;위로 움직이는 중이면 상위 바이트에서 1을 뺀다
AlterYP: sty $07
adc SprObject_Y_Position,x ;수직 위치 + 속도 + 자리올림
sta SprObject_Y_Position,x
SprObject_YMF_Dummy 가 소수 누산기이고, 여기서 넘친 캐리가 adc 를 통해 정수 위치로 올라갑니다. 8비트 정수 + 8비트 소수, 즉 8.8 고정소수점입니다.
// 8.8 고정소수점 중력 적용.
// yPos 는 정수 픽셀, yFrac 은 소수 누산기,
// ySpeed 는 정수 속도, moveForce 는 속도의 소수부.
func imposeGravity(yPos, yFrac, ySpeed, moveForce, gravity uint8) (uint8, uint8, uint8) {
sum := uint16(yFrac) + uint16(moveForce)
yFrac = uint8(sum)
carry := uint8(sum >> 8)
yPos += ySpeed + carry // 캐리가 있을 때만 1픽셀 더 내려간다
// 중력을 속도의 소수부에 누적하고, 넘치면 정수 속도가 1 늘어난다
s := uint16(moveForce) + uint16(gravity)
moveForce = uint8(s)
ySpeed += uint8(s >> 8)
return yPos, yFrac, ySpeed
}
달리기 속도가 점프를 바꾼다#
마리오를 마리오답게 만드는 것은 빨리 달릴수록 높이 뛴다 는 감각입니다. 이게 어떻게 구현돼 있는지 보면 허무할 만큼 단순합니다.
InitJS: ...
lda Player_XSpeedAbsolute ;가로 속도를 가져와
cmp #$09
bcc ChkWtr ;기준값보다 작으면 그대로
iny ;넘을 때마다 Y 를 하나씩 올린다
cmp #$10
bcc ChkWtr
iny
cmp #$19
bcc ChkWtr
iny
cmp #$1c
bcc ChkWtr
iny ;점프의 경우 Y 범위는 0~4
ChkWtr: ...
GetYPhy: lda JumpMForceData,y ;여기서 네 개의 테이블을
sta VerticalForce ;같은 인덱스로 참조한다
lda FallMForceData,y
sta VerticalForceDown
lda InitMForceData,y
sta Player_Y_MoveForce
lda PlayerYSpdData,y
sta Player_Y_Speed
가로 속도를 네 개의 기준값($09, $10, $19, $1c)과 비교해 0~4 사이의 인덱스 를 만듭니다. 그리고 그 인덱스 하나로 네 개의 테이블을 동시에 참조합니다.
JumpMForceData:
.db $20, $20, $1e, $28, $28, $0d, $04 ;상승 중 중력
FallMForceData:
.db $70, $70, $60, $90, $90, $0a, $09 ;하강 중 중력
PlayerYSpdData:
.db $fc, $fc, $fc, $fb, $fb, $fe, $ff ;초기 수직 속도 (정수부)
InitMForceData:
.db $00, $00, $00, $00, $00, $80, $00 ;초기 수직 속도 (소수부)
인덱스 0~4 가 점프용, 5~6 이 수영과 소용돌이용입니다. 점프 구간만 정리하면 이렇습니다.
| 인덱스 | 가로 속도 조건 | 초기 수직 속도 | 상승 중력 | 하강 중력 |
|---|---|---|---|---|
| 0 | 0 ~ $08 | $fc = -4 | $20 | $70 |
| 1 | $09 ~ $0f | $fc = -4 | $20 | $70 |
| 2 | $10 ~ $18 | $fc = -4 | $1e | $60 |
| 3 | $19 ~ $1b | $fb = -5 | $28 | $90 |
| 4 | $1c 이상 | $fb = -5 | $28 | $90 |
빨리 달릴수록 초기 속도가 -4 에서 -5 로 커집니다. 동시에 중력도 $20 에서 $28 로 커집니다. 더 빠르게 솟구치고 더 빠르게 떨어지는 것 이고, 이 조합이 “질주 점프” 특유의 시원한 느낌을 만듭니다.
곱셈도, 미분방정식도, 물리 엔진도 없습니다. 7항짜리 테이블 네 개와 비교 명령 네 개 가 전부입니다. 40년이 지나도 회자되는 조작감이 32바이트에서 나옵니다.
7. 상태바는 어떻게 가만히 있는가#
마지막으로 화면 위쪽 점수판입니다. 배경은 스크롤하는데 점수판은 가만히 있습니다. 패미콤의 스크롤 레지스터는 화면 전체에 적용되는데 말이죠.
Part 1 에서 예고한 sprite 0 hit 트릭입니다. disassembly 에 실물이 있습니다.
Sprite0Data:
.db $18, $ff, $23, $58
4바이트가 곧 0번 스프라이트입니다. Y = $18 (24), 타일 = $ff, 속성 = $23, X = $58 (88). 화면 위에서 24픽셀 내려온 지점, 왼쪽에서 88픽셀 되는 곳 에 눈에 띄지 않는 스프라이트 하나가 항상 놓여 있습니다.
그리고 매 프레임 이런 일이 벌어집니다.
lda Sprite0HitDetectFlag ;플래그 확인
beq SkipSprite0
Sprite0Clr: lda PPU_STATUS ;sprite 0 플래그가 내려갈 때까지 기다린다
and #%01000000 ;(vblank 가 끝나야 내려간다)
bne Sprite0Clr
...
Sprite0Hit: lda PPU_STATUS ;이제 sprite 0 hit 를 기다린다
and #%01000000
beq Sprite0Hit
ldy #$14 ;약간의 지연 - 수평 블랭킹에 들어갈 때까지
HBlankDelay: dey
bne HBlankDelay
SkipSprite0: lda HorizontalScroll ;여기서 스크롤 레지스터를 설정한다
sta PPU_SCROLL_REG
lda VerticalScroll
sta PPU_SCROLL_REG
순서를 풀어 쓰면 이렇습니다.
- VBlank 동안 스크롤 값을 0으로 두고 상태바를 그리게 한다.
- 화면이 그려지기 시작한다. 상태바 영역이 먼저 그려진다.
- PPU 가 Y=24 줄을 그리다가 0번 스프라이트의 불투명 픽셀과 배경의 불투명 픽셀이 겹치는 순간, 상태 레지스터에 플래그를 세운다.
- CPU 는 그때까지
Sprite0Hit루프에서 계속PPU_STATUS를 읽으며 기다리고 있었다. - 플래그가 서면 짧게 더 기다려 수평 블랭킹 구간에 들어간 뒤, 화면을 그리는 도중에 스크롤 레지스터를 진짜 값으로 바꾼다.
- 그 아래부터는 스크롤된 화면이 그려진다.
충돌 판정용으로 만든 기능을 스캔라인 시계로 쓴 것 입니다. 그 대가로 CPU 는 화면 위쪽 24줄이 그려지는 동안 아무 일도 못 하고 루프를 돕니다. Part 1 에서 본 “프레임의 7.6%” 라는 예산이 여기서 더 줄어드는 셈입니다.
그리고 이 트릭 때문에 0번 스프라이트는 항상 배경의 불투명한 픽셀 위에 있어야 합니다. 상태바의 코인 아이콘 근처에 고정돼 있는 이유가 그것입니다. 게임 디자인이 하드웨어 트릭의 요구사항을 떠안은 자리입니다.
마치며#
Part 2 에서 확인한 것을 정리하면 이렇습니다.
- 레벨을 그리지 않고 서술합니다. 오브젝트 하나가 2바이트이고, 그 안에 위치, 종류, 파라미터가 전부 들어갑니다. 월드 1-1 전체가 101바이트, 게임 전체 34개 에어리어가 4,460바이트입니다. 압축 알고리즘은 하나도 쓰지 않았고, 표현을 바꿨을 뿐이라 압축 해제 비용이 0입니다.
- 기본값은 공짜로 깔고 예외만 적습니다. 스테이지 전체의 지면이 헤더의 4비트 하나로 결정되고, 16가지 지형 패턴이 32바이트 테이블에 들어 있습니다.
- 게임의 최소 단위를 하드웨어의 최소 단위에 맞췄습니다. 메타타일이 16 x 16 픽셀인 것은 패미콤이 팔레트를 그 단위로만 고를 수 있기 때문이고, 덕분에 어트리뷰트 테이블이 저절로 만들어집니다.
- 같은 그림을 두 번 팝니다. 구름과 수풀의 CHR 타일 번호는 한 바이트도 다르지 않고, 팔레트만 다릅니다. 구름을 위해 쓴 그래픽 용량은 0입니다.
- 한 프레임에 26바이트만 씁니다. 파서와 렌더러가 8프레임 주기로 돌면서 프레임당 4픽셀을 미리 그리고, 마리오의 최고 속도는 프레임당 2.5픽셀입니다. 게임 디자인의 숫자와 하드웨어 예산이 같은 표에서 결정됐습니다.
- 물리가 전부 정수와 테이블입니다. 가로는 4.4, 세로는 8.8 고정소수점이고, 달리기 속도가 점프를 바꾸는 그 감각은 7항짜리 테이블 네 개에서 나옵니다.
관통하는 원칙은 하나입니다. 데이터를 작게 만들되, 푸는 비용도 0으로 만든다. 그래서 마리오의 모든 기법은 “압축” 이라기보다 “다시 표현하기” 에 가깝습니다.
Part 3 에서는 여기서 한 걸음 더 나갑니다. 1984년의 엘리트는 데이터를 작게 만드는 대신 아예 갖지 않는 쪽 을 택했습니다. 22KB 안에 항성계 2,048개가 있는 우주를 넣은 방법, 즉 저장을 계산으로 바꾸는 절차적 생성을 봅니다.
References#
- SMBDIS.ASM — A Comprehensive Super Mario Bros. Disassembly (doppelganger) — 이 글의 모든 코드 인용과 바이트 수 측정의 근거
- Let’s reverse Super Mario Bros (Retro Reversing) — 카트리지 보드 구성
- NESdev Wiki — PPU attribute tables — 16 x 16 팔레트 단위
- NESdev Wiki — PPU OAM — sprite 0 hit
- NESdev Wiki — Cycle reference chart — VBlank 예산
- 8비트의 벽을 넘은 게임들 Part 1: 그 기계들은 무엇을 못 했는가