diff --git a/presets/gb/bank1.c b/presets/gb/bank1.c new file mode 100644 index 00000000..076bffae --- /dev/null +++ b/presets/gb/bank1.c @@ -0,0 +1,18 @@ +// Everything in this file goes in ROM bank 1. +#pragma bank 1 + +#include +#include "gb/types.h" +#include "gb/hardware.h" +#include "gb/gb.h" + +const char message1[] = "HELLO FROM BANK 1"; + +void get_message1(char* buf) __banked { + const char* p = message1; + while ((*buf++ = *p++)); +} + +uint8_t which_bank1(void) __banked { + return _current_bank; +} diff --git a/presets/gb/bank2.c b/presets/gb/bank2.c new file mode 100644 index 00000000..a5a92532 --- /dev/null +++ b/presets/gb/bank2.c @@ -0,0 +1,18 @@ +// Everything in this file goes in ROM bank 2. +#pragma bank 2 + +#include +#include "gb/types.h" +#include "gb/hardware.h" +#include "gb/gb.h" + +const char message2[] = "HELLO FROM BANK 2"; + +void get_message2(char* buf) __banked { + const char* p = message2; + while ((*buf++ = *p++)); +} + +uint8_t which_bank2(void) __banked { + return _current_bank; +} diff --git a/presets/gb/banking.c b/presets/gb/banking.c new file mode 100644 index 00000000..7ccf693c --- /dev/null +++ b/presets/gb/banking.c @@ -0,0 +1,60 @@ +/* +ROM bank switching for Game Boy. +Code and data in bank1.c and bank2.c live in switchable +ROM banks, mapped at 0x4000-0x7FFF one bank at a time. +A __banked function call switches banks, runs the function, +and switches back. + +The ROM grows to fit the highest bank, and the build sets +the ROM size in the header. We pick an MBC5 mapper here; +the build defaults to MBC5 when a ROM has banks. +*/ + +//#link "gb/sfr.sgb" +//#link "gb/crt0.sgb" +//#link "bank1.c" +//#link "bank2.c" +//#resource "gb/global.sgb" +//#symbol ld GB_MAPPER=0x19 + +#include +#include "gb/types.h" +#include "gb/hardware.h" +#include "gb/gb.h" +#include "gbtext.h" + +// defined in bank1.c and bank2.c +void get_message1(char* buf) __banked; +void get_message2(char* buf) __banked; +uint8_t which_bank1(void) __banked; +uint8_t which_bank2(void) __banked; + +// banked data is only readable while its bank is mapped, +// so banked functions copy it into RAM for us +char buf[20]; + +void main(void) { + DISPLAY_OFF; + BGP_REG = 0xE4; + font_init(); + + put_str(2, 2, "BANK SWITCHING"); + + get_message1(buf); + put_str(2, 5, buf); + + get_message2(buf); + put_str(2, 7, buf); + + // _current_bank tracks the mapped bank + put_str(2, 10, "BANK 1 SAYS"); + put_char(15, 10, '0' + which_bank1()); + put_str(2, 12, "BANK 2 SAYS"); + put_char(15, 12, '0' + which_bank2()); + + SHOW_BKG; + DISPLAY_ON; + while (1) { + wait_vbl_done(); + } +} diff --git a/src/docs/build-directives.md b/src/docs/build-directives.md index 442a5be8..f69dedbe 100644 --- a/src/docs/build-directives.md +++ b/src/docs/build-directives.md @@ -212,6 +212,30 @@ the top of the file: Either pragma slows builds but produces faster and smaller code. +### Game Boy ROM banks + +On the Game Boy, `#pragma bank N` at the top of a file puts its code and +constant data in ROM bank *N*, which the CPU sees at `$4000-$7FFF`. Link +the file from your main file, and call its functions through `__banked` +prototypes. Each call switches to the function's bank and back again: + +~~~c +// bank1.c +#pragma bank 1 +void say_hello(char* buf) __banked { ... } + +// main.c +//#link "bank1.c" +//#symbol ld GB_MAPPER=0x19 +void say_hello(char* buf) __banked; +~~~ + +The ROM grows to fit the highest bank, and the build writes the ROM size +into the header. If `GB_MAPPER` is still 0 (ROM only), the build selects +MBC5. A bank holds 16 KB; overflowing it is a build error. Banked data +can only be read while its bank is mapped, so copy it out from a +`__banked` function. See the *ROM Bank Switching* preset. + ## File extensions The extension of your source file determines the tool used to assemble diff --git a/src/platform/gb.ts b/src/platform/gb.ts index 17a568a5..92ff6e50 100644 --- a/src/platform/gb.ts +++ b/src/platform/gb.ts @@ -16,6 +16,7 @@ const GB_PRESETS: Preset[] = [ { id: 'galaxy.c', name: 'Window + Sprites' }, { id: 'gbdecompress.c', name: 'Decompression' }, { id: 'music.c', name: 'Music Player' }, + { id: 'banking.c', name: 'ROM Bank Switching' }, { id: 'siegegame.c', name: 'Siege Game' }, { id: 'climber.c', name: 'Climber Game' }, { id: 'chase.c', name: "Shiru's Chase Game" }, diff --git a/src/worker/platforms.ts b/src/worker/platforms.ts index 042d29e9..bef73f9d 100644 --- a/src/worker/platforms.ts +++ b/src/worker/platforms.ts @@ -391,7 +391,8 @@ export var PLATFORM_PARAMS = { arch: 'gbz80', code_start: 0x0, // ROM starts @ 0x0, header @ 0x100, etc. codeseg_start: 0x200, // _CODE area starts here - rom_size: 0x8000, + rom_size: 0x8000, // grows to fit the highest bank + rom_banking: { size: 0x4000, window: 0x4000 }, // #pragma bank N -> _CODE_N @ 0xN4000 data_start: 0xc0a0, data_size: 0x1f60, stack_end: 0xe000, diff --git a/src/worker/tools/sdcc.ts b/src/worker/tools/sdcc.ts index 69b05407..92e79d2d 100644 --- a/src/worker/tools/sdcc.ts +++ b/src/worker/tools/sdcc.ts @@ -15,45 +15,88 @@ function hexToArray(s, ofs) { return arr; } -function parseIHX(ihx: string, rom_start: number, rom_size: number, errors: WorkerError[]) { +/** + * Banked ROM layout, following the GBDK/devkitSMS convention: the linker places + * bank N's area at the virtual address (N << 16) | window, and the ROM image + * holds bank N at file offset N * size. + */ +export interface ROMBanking { + size: number; // bytes per bank (power of 2) + window: number; // CPU address the switchable bank is mapped at +} + +/** Linker `-b` args for each `_CODE_N` area (N > 0) the object files declare. */ +export function bankedAreaArgs(rels: string[], banking: ROMBanking): string[] { + let banks = new Set(); + for (let rel of rels) { + let re = /^A _CODE_(\d+) /gm, m; + while ((m = re.exec(rel))) { + let n = parseInt(m[1]); + if (n > 0) banks.add(n); + } + } + let args = []; + for (let n of Array.from(banks).sort((a, b) => a - b)) + args.push('-b', `_CODE_${n}=0x${((n << 16) | banking.window).toString(16)}`); + return args; +} + +/** + * Convert Intel HEX to a ROM image of rom_size bytes. With banking, records + * above 64 KB are placed by bank, and the image grows to the next power of 2 + * that holds the highest bank. + */ +export function parseIHX(ihx: string, rom_start: number, rom_size: number, errors: WorkerError[], banking?: ROMBanking) { var output = new Uint8Array(new ArrayBuffer(rom_size)); - var high_size = 0; + var upper = 0; // from type 04 (extended linear address) records for (var s of ihx.split("\n")) { if (s[0] == ':') { var arr = hexToArray(s, 1); var count = arr[0]; - var offset = (arr[1] << 8) + arr[2] - rom_start; + var address = upper + (arr[1] << 8) + arr[2]; + var offset = address - rom_start; var rectype = arr[3]; - //console.log(rectype,address.toString(16),count,arr); if (rectype == 0) { + if (banking && address >= 0x10000) { + let bank = address >>> 16; + let low = address & 0xffff; + if (low < banking.window || low + count > banking.window + banking.size) { + errors.push({line:0, msg:`Bank ${bank} overflows its 0x${banking.size.toString(16)}-byte window at 0x${low.toString(16)}`}); + continue; + } + offset = bank * banking.size + low - banking.window; + if (offset + count > output.length) { + let newsize = output.length; + while (newsize < offset + count) newsize *= 2; + let grown = new Uint8Array(newsize); + grown.set(output); + output = grown; + } + } // The linker also emits records for whatever it placed outside // the ROM -- initialized data, or a GSINIT area that landed in // the _DATA area's RAM. Those bytes are not part of the ROM // image; reading past the end of output to check them yields // undefined, which used to be reported as an overlap. - if (offset < 0 || offset + count > rom_size) { - console.log(`skipping IHX record outside ROM: 0x${offset.toString(16)} +${count}`); + if (offset < 0 || offset + count > output.length) { + console.log(`skipping IHX record outside ROM: 0x${address.toString(16)} +${count}`); continue; } if (output[offset] !== 0) { errors.push({line:0,msg:`IHX overlap offset 0x${(offset).toString(16)}`}); } for (var i = 0; i < count; i++) { - var b = arr[4 + i]; - output[i + offset] = b; + output[i + offset] = arr[4 + i]; } - if (i + offset > high_size) high_size = i + offset; } else if (rectype == 1) { break; + } else if (rectype == 4) { + upper = ((arr[4] << 8) | arr[5]) << 16; } else { console.log(s); // unknown record type } } } - // TODO: return ROM anyway? - if (high_size > rom_size) { - //errors.push({line:0, msg:"ROM size too large: 0x" + high_size.toString(16) + " > 0x" + rom_size.toString(16)}); - } return output; } @@ -177,7 +220,27 @@ export function linkSDLDZ80(step: BuildStep) { // //#symbol ld (sdldz80 uses -g sym=expr) and //#flag ld args.push.apply(args, linkSymbolArgs('sdldz80', params.symbols && params.symbols.linker)); args.push.apply(args, extraArgsFor('sdldz80', params.buildArgs)); - args.push.apply(args, step.args); + var objargs = step.args; + if (params.rom_banking) { + // place each #pragma bank N area, unless a //#flag ld already did + let banked = objargs.filter((fn) => fn.endsWith('.rel') && bankedAreaArgs([getWorkFileAsString(fn)], params.rom_banking).length); + let bargs = bankedAreaArgs(banked.map(getWorkFileAsString), params.rom_banking); + for (let i = 0; i < bargs.length; i += 2) { + let area = bargs[i + 1].split('=')[0] + '='; + if (!args.some((a) => a.startsWith(area))) + args.push(bargs[i], bargs[i + 1]); + } + // Link banked objects just before the last bank 0 object. The + // linker writes an IHX extended address record when it flushes + // buffered data, using the address width of the module it is + // reading at that moment; the XL2 (16-bit) libraries come last, so + // the last object's upper address is lost -- harmless for bank 0. + // The first objects stay first: they set the area order crt0 needs. + let rest = objargs.filter((fn) => !banked.includes(fn)); + if (banked.length && rest.length) + objargs = rest.slice(0, -1).concat(banked, rest.slice(-1)); + } + args.push.apply(args, objargs); //console.log(args); execMain(step, LDZ80, args); if (errors.length) { @@ -191,7 +254,7 @@ export function linkSDLDZ80(step: BuildStep) { if (!anyTargetChanged(step, ["main.ihx", "main.noi"])) return; // parse binary file - var binout = parseIHX(hexout, params.rom_start !== undefined ? params.rom_start : params.code_start, params.rom_size, errors); + var binout = parseIHX(hexout, params.rom_start !== undefined ? params.rom_start : params.code_start, params.rom_size, errors, params.rom_banking); if (errors.length) { return { errors: errors }; } @@ -240,8 +303,14 @@ export function linkSDLDZ80(step: BuildStep) { } } } - // gameboy: compute checksum + // gameboy: fix up header for the final ROM size, compute checksum if (step.params.arch === 'gbz80') { + if (binout.length > 0x8000) { + // ROM size code n means 32 KB << n + binout[0x148] = Math.log2(binout.length / 0x8000); + // a ROM-only cart can't switch banks; MBC5 accepts MBC1-style bank writes + if (binout[0x147] === 0) binout[0x147] = 0x19; + } var checksum = 0; for (var address = 0x0134; address <= 0x014C; address++) { checksum = checksum - binout[address] - 1; @@ -258,6 +327,26 @@ export function linkSDLDZ80(step: BuildStep) { } } +/** + * sdcc 3.6.5 compiles a __banked call to `call banked_call; .dw _fn; .dw 0`, + * with a "PENDING: bank support" comment where the bank number belongs. Supply + * it the way later sdcc versions do: a file with `#pragma bank N` defines + * `b_fn == N` for each function, and call sites reference `b_fn`. + */ +export function fixBankedCalls(asm: string): string { + asm = asm.replace(/^(\s*\.dw\s+(_\w+)\s*\n\s*\.dw\s+)0\s*; PENDING: bank support/gm, + (_m, head, fn) => head + 'b' + fn); + let m = /^\s*\.area\s+_CODE_(\d+)\b/m.exec(asm); + if (m && parseInt(m[1]) > 0) { + let defs = []; + let re = /^(_\w+)::/gm, f; + while ((f = re.exec(asm))) + defs.push(`\t.globl b${f[1]}\nb${f[1]} == ${m[1]}`); + if (defs.length) asm += '\n' + defs.join('\n') + '\n'; + } + return asm; +} + export function compileSDCC(step: BuildStep): BuildStepResult { gatherFiles(step, { @@ -332,6 +421,7 @@ export function compileSDCC(step: BuildStep): BuildStepResult { // massage the asm output var asmout = FS.readFile(outpath, { encoding: 'utf8' }); asmout = " .area _HOME\n .area _CODE\n .area _INITIALIZER\n .area _DATA\n .area _INITIALIZED\n .area _BSEG\n .area _BSS\n .area _HEAP\n" + asmout; + if (isGBZ80) asmout = fixBankedCalls(asmout); putWorkFile(outpath, asmout); } return { diff --git a/test/unit/testsdccbanking.ts b/test/unit/testsdccbanking.ts new file mode 100644 index 00000000..e8713886 --- /dev/null +++ b/test/unit/testsdccbanking.ts @@ -0,0 +1,65 @@ +import assert from "assert"; +import { describe, it } from "mocha"; +import { parseIHX, bankedAreaArgs, fixBankedCalls } from "../../src/worker/tools/sdcc"; + +// Intel HEX record with checksum +function rec(type: number, addr: number, data: number[]): string { + let bytes = [data.length, addr >> 8, addr & 0xff, type, ...data]; + let sum = bytes.reduce((a, b) => a + b, 0); + bytes.push((-sum) & 0xff); + return ':' + bytes.map((b) => b.toString(16).padStart(2, '0').toUpperCase()).join(''); +} +const GB_BANKING = { size: 0x4000, window: 0x4000 }; + +describe("sdcc ROM banking", function () { + + it("places type 04 records by bank and grows the ROM", function () { + const ihx = [ + rec(0, 0x0100, [0x11]), + rec(4, 0, [0x00, 0x01]), rec(0, 0x4000, [0x22]), + rec(4, 0, [0x00, 0x05]), rec(0, 0x4001, [0x55]), + rec(4, 0, [0x00, 0x00]), rec(0, 0x0200, [0x33]), + rec(1, 0, []), + ].join('\n'); + const errors = []; + const rom = parseIHX(ihx, 0, 0x8000, errors, GB_BANKING); + assert.deepStrictEqual(errors, []); + assert.strictEqual(rom.length, 0x20000); // bank 5 -> 0x14001, rounded up to 128 KB + assert.strictEqual(rom[0x100], 0x11); + assert.strictEqual(rom[0x4000], 0x22); + assert.strictEqual(rom[0x14001], 0x55); + assert.strictEqual(rom[0x200], 0x33); + }); + + it("reports a bank that overflows its window", function () { + const ihx = [rec(4, 0, [0x00, 0x01]), rec(0, 0x8000, [0x22]), rec(1, 0, [])].join('\n'); + const errors = []; + parseIHX(ihx, 0, 0x8000, errors, GB_BANKING); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].msg, /Bank 1 overflows/); + }); + + it("keeps the fixed size without banking", function () { + const ihx = [rec(0, 0x0000, [0x01]), rec(4, 0, [0x00, 0x01]), rec(0, 0x0000, [0x02]), rec(1, 0, [])].join('\n'); + const rom = parseIHX(ihx, 0, 0x8000, [], undefined); + assert.strictEqual(rom.length, 0x8000); + assert.strictEqual(rom[0], 0x01); // record above 64 KB is skipped, not folded onto 0 + }); + + it("generates -b args for #pragma bank areas", function () { + const rels = ["XL3\nA _CODE size 0 flags 0\nA _CODE_2 size 1F flags 0\n", "A _CODE_1 size 4 flags 0\nA _CODE_0 size 0 flags 0\n"]; + assert.deepStrictEqual(bankedAreaArgs(rels, GB_BANKING), + ['-b', '_CODE_1=0x14000', '-b', '_CODE_2=0x24000']); + }); + + it("fills in the bank of a __banked call and defines b_ symbols", function () { + const caller = "\tcall\tbanked_call\n\t.dw\t_foo\n\t.dw 0 ; PENDING: bank support\n"; + assert.ok(fixBankedCalls(caller).includes("\t.dw b_foo")); + const callee = "\t.area _CODE_3\n_foo::\n\tret\n_bar::\n\tret\n"; + const out = fixBankedCalls(callee); + assert.ok(out.includes("b_foo == 3")); + assert.ok(out.includes("b_bar == 3")); + // bank 0 code gets no b_ symbols + assert.ok(!fixBankedCalls("\t.area _CODE\n_foo::\n").includes("b_foo")); + }); +});