원문 캡처 · bumbershootsoft.wordpress.com
원문 캡처 · bumbershootsoft.wordpress.com

지난 2주 동안 Apple IIgs용 Simulated Evolution을 두 가지로 만들었다. Motorola 68000과 Intel 8086 버전의 C 코드를 포팅한 버전, 그리고 Commodore 64와 Atari 800 버전의 8비트 6502 어셈블리 코드를 포팅한 버전이다. 8비트 포트가 C 버전보다 꽤 빨랐지만, ProDOS 8에서는 시스템 기능 대부분을 쓸 수 없었다. 16KB Atari 400에서도 돌아가는 프로그램이라 그 자체로는 문제가 아니지만, 이 프로그램은 16비트 운영체제의 기능을 활용할 수 있을 만큼 복잡하다.

이번에는 ProDOS 8용 프로그램을 GS/OS용으로 바꾸면서 GS/OS Toolbox를 C 포트처럼 사용하고, 최신 컴퓨터에서 하듯 프로그램을 구성하려 했다. Merlin 어셈블리 소스만으로도 배포 가능한 수준까지 만들 수 있었지만, 사용 중인 도구 체인만으로는 모든 목표를 이루지 못했다.

코드 재배치와 메모리 구성

클래식 매킨토시에서는 프로그램 코드가 위치 독립적으로 컴파일됐다. 프로그램 카운터나 시작할 때 시스템이 A5 레지스터에 전달한 값을 기준으로 내부 데이터에 접근했기 때문이다. GS/OS는 Amiga의 “hunk”, Atari ST의 “PRG”, MS-DOS의 “EXE”와 더 비슷하다. 실행 파일에는 적재 시 주소를 조정해야 하는 위치 목록이 들어간다.

65816에서는 24비트 주소가 레지스터에 들어가지 않으며, 8비트 모드에서는 일반 주소도 들어가지 않는다. 그래서 16비트 코드에서는 주소를 .XA에 넣는 LDA #^addr; LDX #addr 같은 명령열을 흔히 사용한다. 이 패턴은 시뮬레이션 핵심 코드 곳곳에 있다.

ByteWorks는 ORCA 도구용 OMF를 만들고 애플에 라이선스하면서 이 문제를 해결했다. OMF 재배치 항목은 주소가 차지할 바이트 수뿐 아니라 재배치한 값을 먼저 몇 비트 이동할지도 지정한다. 덕분에 6502 방식의 코드를 더 자연스럽게 쓸 수 있다.

다만 Merlin이 처리하지 못하는 표현도 있었다. Ophis 코드의 CMP #(>BUG_DNA)+6은 Merlin에서 CMP #{BUG_DNA/256}+6이 됐지만, 어셈블러 출력 형식이 이를 처리하지 못했다. 이동은 링크 로더의 바이트 추출 단계에서 마지막에 해야 했다. CMP #>{BUG_DNA+$0600}으로 고치면 되며, 이는 프로그램을 실제로 변경하는 수정은 아니다. 이 변경을 ProDOS 8 버전이나 C64 개념 증명 포트에 적용해도 비트 단위로 정확히 같은 결과가 나온다.

ProDOS 8 버전은 코드와 데이터의 메모리 배치를 정확히 지정했지만, GS/OS에서는 이를 포기해야 했다. Merlin은 코드에 속한 레이블만 재배치 가능하게 지정할 수 있다. 코드 밖의 레이블은 영 페이지에 있거나 화면 메모리·입출력 레지스터처럼 고정 주소를 가리켜야 한다.

65816은 영 페이지를 “직접 페이지”라고 부르며, 뱅크 0의 연속된 256바이트 어디에나 배치할 수 있다. GS/OS는 각 16비트 응용 프로그램에 독립적인 직접 페이지를 할당하므로 256바이트를 전부 사용할 수 있다. 기존 8비트 환경에서는 시뮬레이션 핵심 코드가 영 페이지에서 3바이트만 사용하도록 제한했지만, 이제 벌레들의 상태를 나타내는 약 4KB 배열을 제외한 모든 변수를 직접 페이지에 넣을 수 있다.

데이터 뱅크와 ProDOS 8 기능 대체

데이터 뱅크 레지스터는 직접 페이지 밖에서 적재·저장 명령이 접근할 위치를 정한다. 이 프로그램에서 직접 페이지 밖에 있는 데이터는 벌레 배열, 화면·키보드의 메모리 매핑 입출력, 프로그램 코드에 포함된 표와 상수다. 핵심 코드가 벌레 배열과 자체 표에 언제든 접근한다고 가정하므로 데이터 뱅크 레지스터 값은 프로그램 뱅크 레지스터와 같아야 하고, 벌레 배열도 프로그램과 같은 뱅크에 있어야 한다. PHK;PLB 두 명령으로 데이터 뱅크를 설정할 수 있었다. 배열은 우선 프로그램 끝에 4KB의 0을 정의해 배치했다.

asm
BUG_X DS 256
BUG_Y DS 256
BUG_AGE DS 512
BUG_HP DS 512
BUG_DIR DS 256
BUG_DNA DS 1536

이 데이터를 실행 파일에 그대로 포함해도 C 버전의 절반 크기다.

ProDOS 8 응용 프로그램은 펌웨어 호출과 메모리 매핑 입출력을 8비트 Apple처럼 사용할 수 있지만, 16비트 응용 프로그램이 되면 이를 쓸 수 없다. 메모리 매핑 입출력은 이미 데이터 뱅크 설정 과정에서 다뤘고, 뱅크를 $00에서 $E0으로 바꾸면 됐다. 펌웨어 호출은 시작 화면에 쓴 HOME과 COUT뿐이었다. Apple II Platform Guide의 GS/OS Hello World에서 사용한 .CONSOLE 장치로 대체할 수 있었다. 유일한 ProDOS 8 시스템 호출인 QUIT도 Hello World의 코드를 가져와 바꿨다.

Apple IIe처럼 40열 모드를 설정하려고 “80열 모드” 소프트 스위치를 찾아 끄는 방식은 작동하지 않았다. 정확한 이유는 알아내지 못했지만, GS/OS는 $00-$1F 범위의 일부 ASCII 제어 문자를 터미널 제어 문자로 사용한다. $0C는 화면을 지우고, $11과 $12는 각각 40열과 80열 모드를 설정하며, $0F와 $0E는 역상 표시를 켜고 끈다. 이 문자들로 기존 타이틀 화면을 재현할 수 있었다.

IIgs의 2.8MHz 속도에서도 벌레가 15마리쯤 되기 전까지 시뮬레이션이 지나치게 빠르게 돌아갔다. C 버전에 있던 속도 제한을 되살리고, 필요할 때 끌 수 있도록 하려 했다. ProDOS 8에서는 C 버전에서 사용한 GetTick()을 쓸 수 없어 GS/OS Toolbox를 이용해야 했다.

GS/OS Toolbox와 타이머

GS/OS 기능 대부분은 GS/OS Toolbox에 있다. 호출은 파스칼 방식으로 처리한다. 인수는 왼쪽에서 오른쪽으로 스택에 넣고, 호출된 함수가 반환 전에 인수를 제거한다. 반환값을 위한 공간은 인수 앞에 호출자가 할당한다. IIgs에서는 16비트 모드로 전환하고 함수 선택자를 X 레지스터에 넣은 뒤 JSL $E10000으로 공통 Toolbox 진입점을 호출한다.

C 코드에서 호출한 Toolbox 함수는 인수 없이 32비트 값을 반환하는 GetTick() 하나였다. 8비트 코드와 맞추기 위해 양쪽에서 값을 처리했다.

asm
GET_TIME REP #$30 ; 16-bit mode
 PHA ; Push enough room for 
 PHA ; the 32-bit return value
 LDX #$2503 ; Function selector: LONG GetTick(void)
 JSL $E10000 ; Do the call
 PLA ; Pull 32-bit result into .AX
 PLX ; (low word first)

 SEP #$30 ; Return to 8-bit mode
 XBA ; Get the old high byte
 TAX ; Put it in X register
 XBA ; Swap the low byte back into place
 RTS

C 코드에 있던 프레임 속도 제한은 메인 루프에 다음처럼 옮겼다.

asm
:STEP JSR GET_TIME
 STA TIMER
 JSR RUN_STEP
:ZZZ JSR GET_TIME
 CMP TIMER
 BEQ :ZZZ
 JMP MAINLP

이 코드는 내 컴퓨터에서는 작동했다. 하지만 여기서 끝내지 않고 IIgs가 지향했던 방식에 맞춰 Toolbox를 더 제대로 초기화하기로 했다. GS/OS Toolbox Reference는 GetTick()을 담은 Miscellaneous Tool Set을 사용하려면 MTStartUp을 호출해 향후 시스템 버전과의 호환성을 보장하라고 안내한다.

Tool Locator, Memory Manager, Miscellaneous Tools의 시작·종료 루틴을 올바른 순서로 호출했다. Memory Manager는 종료 호출에 전달할 ID를 반환한다.

asm
INIT_TOOLBOX REP #$30
 LDX #$0201 ; Toolbox call: TLStartUp
 JSL $E10000
 PHA ; Toolbox call: MMStartUp
 LDX #$0202
 JSL $E10000
 PLA
 STA MEM_ID ; Save return value as process ID
 LDX #$0203 ; Toolbox call: MTStartUp
 JSL $E10000
 SEP #$30
 RTS

EXIT_TOOLBOX REP #$30
 LDX #$0303 ; Toolbox call: MTShutDown
 JSL $E10000
 LDA MEM_ID ; Toolbox call: MMShutDown(MEM_ID)
 PHA
 LDX #$0302
 JSL $E10000
 LDX #$0301 ; Toolbox call: TLShutDown
 JSL $E10000
 SEP #$30
 RTS

그렇다고 GetTick()이 항상 동작하는 것은 아니다. 문서에 따르면 틱 카운터는 Heartbeat Interrupt Handler가 설치되고 VBL 인터럽트가 활성화된 경우에만 증가한다. Event Manager가 활성 상태면 핸들러가 설치되며, 그렇지 않으면 Heartbeat Task를 하나 이상 설치해야 한다.

응용 프로그램은 Event Manager를 시작하지 않았지만 Finder에서 실행됐다. Finder는 GUI 응용 프로그램을 위해 Event Manager를 시작했을 가능성이 있다. Miscellaneous Tool Set 초기화가 선택 사항이라는 점은 일부 상태가 유지될 수 있음을 시사한다. 따라서 기존 C 코드도 Finder에서 물려받은 설정 덕분에 작동했을 수 있다. 이는 문서 작성 당시 시스템 프로그램을 더 엄격히 격리하는 미래의 GS/OS나, 부팅 시 Toolbox를 제공하지만 직접 초기화하지 않는 더 저수준의 GS/OS를 염두에 둔 것일 수 있다. 다만 이는 추측이다.

시작·종료 루틴에서 Heartbeat Task를 만들고 제거하고, VBLANK 인터럽트도 활성화했다.

asm
* Added to INIT_TOOLBOX
 PEA ^BUG_HEART ; Push the high word first
 PEA BUG_HEART ; for 32-bit arguments
 LDX #$1203 ; Toolbox call: SetHeartBeat
 JSL $E10000
 PEA $0002 ; vblEnable
 LDX #$2303 ; Toolbox call: IntSource
 JSL $E10000

* Added to EXIT_TOOLBOX
 PEA ^BUG_HEART
 PEA BUG_HEART
 LDX #$1303 ; Toolbox call: DelHeartBeat
 JSL $E10000

Heartbeat Task는 데이터 구조 바로 뒤에 처리기 코드를 두는 형식이다. 문서의 예제를 조금 바꿔 매 프레임 화면 상단의 픽셀을 증가시키도록 했다.

asm
BUG_HEART ADRL 0 ; Link pointer for use by the OS
:FRAMES DW 1 ; Number of interrupts until call fires
 DW $A55A ; Magic number to say this is a real task
 
 REP #$20 ; 16-bit accumulator
 PHK ; Set data bank to program bank
 PLB
 INC :FRAMES ; Reset frame counter to 1
 LDAL $E12000 ; Load top 4 pixels in screen
 INC ; Increment them as a 16-bit value
 STAL $E12000 ; And write them back
 RTL

화면에 반짝이는 픽셀이 생기는 것을 확인한 뒤에는 작업을 단순화했다. 틱 카운터 기능만 필요하면 카운트가 0인 작업을 설치해도 된다.

asm
BUG_HEART DW 0,0,0,$A55A
 RTL

틱 카운트를 확인할 때마다 Toolbox를 호출할 필요도 없다. 시스템이 RAM 어딘가에 저장한 카운터를 읽으면 되지만, 위치는 보장되지 않는다. Miscellaneous Tool Set의 GetAddr 호출로 Tick Counter 항목 5의 주소를 얻었다.

asm
 PHA ; Toolbox call: GetAddr
 PHA
 PEA $0005 ; Tick counter
 LDX #$1603
 JSL $E10000
 PLA
 STA TIME_PTR
 PLA
 STA TIME_PTR+2

이제 JSR GET_TIME 대신 LDA [TIME_PTR]로 틱 카운트의 하위 바이트를 읽을 수 있다. 마지막으로 65816의 WAI 명령을 대기 루프에 넣었다. WAI는 인터럽트가 올 때까지 프로세서를 대기 상태로 둔다. IIgs에는 IRQ 원인이 7가지 있을 수 있고 Finder가 일부를 설정했을 가능성도 있으므로, 루프는 유지하되 다음 프레임까지 기다리도록 했다.

asm
:STEP LDA [TIME_PTR] ; Save time at start of simulation step
 STA TIMER
 JSR RUN_STEP ; Run that simulation step
 LDA WARP ; Are we going warp speed?
 BNE MAINLP ; If so, don't bother delaying at all
:ZZZ LDA [TIME_PTR] ; Otherwise, has it been at least a frame?
 CMP TIMER
 BNE MAINLP ; If so, time for the next iteration
 WAI ; Otherwise wait for something to happen
 JMP :ZZZ ; and try again

새 버전은 GS/OS에 더 자연스럽게 통합되고 시스템과 더 잘 상호작용하며 CPU 부담도 줄였다. 끝에 4KB의 0을 포함해도 처음 시작한 C 프로그램의 절반보다 작다. 이 버전은 배포할 수 있을 만큼 완성됐다. 워프 모드에서는 속도 제한 없이 실행해 종 분화 이벤트를 만든 뒤, 화면을 캡처할 수 있도록 속도를 낮출 수도 있었다. 다만 작업은 여기서 끝나지 않는다. 이 프로그램을 할로윈 특별판의 주제로 삼을 계획이다.

참고 자료: EGA 팔레트 프로그램을 ProDOS 8에서 GS/OS로 옮긴 과정, ProDOS 8에서 불러오기 어려웠던 대형 멀티 세그먼트 프로그램, 클래식 매킨토시의 프로그램 시작 과정, 프로그램 적재 과정 개요, Amiga에서 Copper 목록 재배치 문제를 다룬 글, Apple II 플랫폼 안내서, Atari ST의 Line-A 인터럽트 벡터.