diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 752e8e5cf..e6ae2674f 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -2386,7 +2386,7 @@ class AppCompiler { [...this.strLits.keys()].reduce((a, s) => a + s.length + 1, 0) + this.title.length + 1; const pairCount = this.styleTable.pairs.length; const fontBytes = - this.target.name === "esp32" || this.target.name === "playdate" ? 95 * 8 : 95 * 32; + this.target.name === "gba" || this.target.name === "nes" ? 95 * 32 : 95 * 8; const styleBytes = this.target.name === "gba" ? pairCount * 16 * 2 + pairCount + 3 @@ -2444,31 +2444,6 @@ function emitFont1bpp(): string { return `const u8 vp_font_tiles[] = { ${bytes.join(",")} };`; } -/** DMG can't do per-tile palettes: styles are baked as glyph copies. - * Style 0 = ink shade 3 on paper 0, style 1 = inverse. 2bpp interleaved. */ -function emitFontGb(): string { - const bytes: number[] = []; - for (const [ink, paper] of [ - [3, 0], - [0, 3], - ]) { - for (let g = 0; g < 95; g++) { - const bitmap = FONT8[g]; - for (let y = 0; y < 8; y++) { - let lo = 0; - let hi = 0; - for (let x = 0; x < 8; x++) { - const v = bitmap[y] & (0x80 >> x) ? ink : paper; - if (v & 1) lo |= 0x80 >> x; - if (v & 2) hi |= 0x80 >> x; - } - bytes.push(lo, hi); - } - } - } - return `const u8 vp_font_tiles[] = { ${bytes.join(",")} };`; -} - /** NES: same two styles, 2bpp planar (8 bytes plane 0, then plane 1). * Subpal color 1 = paper, 3 = ink. Exported: rom.ts bakes these into * CHR-ROM (tile 0 blank, then the 190 glyphs). */ @@ -2532,7 +2507,13 @@ function emitTargetData(target: VaporTarget, styles: StyleTable): string { ); } case "gb": - return `${emitFontGb()}\n${styleTable}`; + /* DMG can't do per-tile palettes, so styles are baked as glyph copies: + * style 0 = ink shade 3 on paper 0, style 1 = inverse. The 2 x 95 x 16 B + * of 2bpp interleaved tile data holds no bit FONT8 does not — shade 3 + * sets both planes and shade 0 clears both, so every row is `bits, bits` + * and style 1 is style 0 complemented. Ship 1bpp; upload_font in + * vapor/runtime/gb/vapor_gb.c expands it into VRAM. */ + return `${emitFont1bpp()}\n${styleTable}`; case "nes": /* NES font ships as CHR-ROM (rom.ts); only the style map is C data. */ return styleTable; diff --git a/vapor/runtime/gb/vapor_gb.c b/vapor/runtime/gb/vapor_gb.c index 67be92f7f..01ee374fb 100644 --- a/vapor/runtime/gb/vapor_gb.c +++ b/vapor/runtime/gb/vapor_gb.c @@ -122,10 +122,25 @@ static u8 read_keys(void) { } static void upload_font(void) { - /* 2 styles x 95 glyphs, 2bpp interleaved; tile 0 stays blank */ + /* vp_font_tiles is 1bpp: 95 glyphs x 8 rows, MSB = leftmost pixel. DMG tiles + * are 2bpp interleaved (per row: plane 0 byte, then plane 1 byte), and the + * two baked styles are shade 3 on 0 and its inverse — so a set bit means + * shade 3 (both planes set) and a clear bit means shade 0 (both clear). + * Every row is therefore `bits, bits`, and style 1 is style 0 complemented. + * Expand here rather than shipping 3040 B of ROM holding 760 B of bits. + * Tile 0 stays blank; style 1 follows style 0's 95 tiles. */ volatile u8 *d = VRAM8(0x8000 + 16); + volatile u8 *inv = VRAM8(0x8000 + 16 + 95 * 16); + const u8 *src = vp_font_tiles; u16 i; - for (i = 0; i < (u16)(2 * 95 * 16); i++) d[i] = vp_font_tiles[i]; + for (i = 0; i < (u16)(95 * 8); i++) { + u8 bits = src[i]; + *d++ = bits; + *d++ = bits; + bits = (u8)~bits; + *inv++ = bits; + *inv++ = bits; + } } void main(void) { diff --git a/vapor/runtime/vapor.h b/vapor/runtime/vapor.h index a37c758a9..6c73ab8d7 100644 --- a/vapor/runtime/vapor.h +++ b/vapor/runtime/vapor.h @@ -108,7 +108,8 @@ u16 app_debug_state(volatile u8 *out); /* mirror reactive state; returns bytes * /* generated data the runtime uploads at boot (per-target encodings): * GBA: vp_font_tiles 95x32B 4bpp, vp_palettes/vp_palette_count/vp_backdrop - * GB: vp_font_tiles (2 styles x 95) x 16B 2bpp interleaved + * GB: vp_font_tiles 95x8B 1bpp; upload_font expands to 2 styles x 95 + * tiles of 2bpp interleaved VRAM (style 1 = style 0 inverted) * NES: vp_font_tiles (2 styles x 95) x 16B 2bpp planar * ESP32: vp_font_tiles 95x8B 1bpp, direct RGB565 ink/paper tables * Playdate: vp_font_tiles 95x8B 1bpp, vp_pal_style maps pair -> normal/inverse diff --git a/vapor/tests/compiler.test.ts b/vapor/tests/compiler.test.ts index cb2f1a155..ab2e99d49 100644 --- a/vapor/tests/compiler.test.ts +++ b/vapor/tests/compiler.test.ts @@ -8,6 +8,13 @@ import { esp32BuildId } from "../compiler/esp32.ts"; import { FONT8 } from "../compiler/font.gen.ts"; const ENTRY = join(import.meta.dir, "..", "examples", "todo", "todo.tsx"); +const SIX_BUTTON = join( + import.meta.dir, + "..", + "examples", + "playdate-six-button", + "playdate-six-button.tsx", +); const HEADER = ` import { computed, ref } from "vue"; @@ -52,6 +59,51 @@ describe("pocket vapor compiler", () => { expect(a.plan).toContain("pools"); }); + test("gb emits the font 1bpp and leaves the other targets' encodings alone", async () => { + const source = await Bun.file(ENTRY).text(); + const fontOf = (target: "gb" | "gba" | "nes" | "esp32") => { + const c = compileVaporApp(ENTRY, source, "VAPOR TODO", target).c; + const m = c.match(/const u8 vp_font_tiles\[\] = \{ ([^}]*) \};/); + return m === null ? null : m[1].split(",").map(Number); + }; + + // GB ships FONT8 itself; vapor_gb.c's upload_font builds the 2 styles x + // 95 x 16 B of 2bpp VRAM from it (proven byte-for-byte in gb-font.test.ts). + const gb = fontOf("gb"); + expect(gb).toHaveLength(95 * 8); + expect(gb).toEqual(FONT8.flat()); + const gbC = compileVaporApp(ENTRY, source, "VAPOR TODO", "gb").c; + expect(gbC).toContain("const u8 vp_pal_style["); + expect(gbC).not.toContain("vp_palettes"); + expect(compileVaporApp(ENTRY, source, "VAPOR TODO", "gb").c).toBe(gbC); + + // GBA stays 4bpp, 95 x 32 B, pixels drawn from ink(1)/paper(2) only. + const gba = fontOf("gba")!; + expect(gba).toHaveLength(95 * 32); + expect([...new Set(gba.flatMap((b) => [b & 0xf, b >> 4]))].sort()).toEqual([1, 2]); + + // NES ships its font as CHR-ROM, so it has no C font table at all. + expect(fontOf("nes")).toBeNull(); + // esp32 and playdate were already 1bpp and must be untouched. (todo.tsx + // has no playdate input mapping, so that target reads the six-button one.) + expect(fontOf("esp32")).toEqual(FONT8.flat()); + const pdSource = await Bun.file(SIX_BUTTON).text(); + const pd = compileVaporApp(SIX_BUTTON, pdSource, "PLAYDATE SIX", "playdate").c.match( + /const u8 vp_font_tiles\[\] = \{ ([^}]*) \};/, + ); + expect(pd![1].split(",").map(Number)).toEqual(FONT8.flat()); + }); + + test("the memory plan prices the gb font at its emitted 760 B", async () => { + const source = await Bun.file(ENTRY).text(); + const planFont = (target: "gb" | "gba" | "nes") => + compileVaporApp(ENTRY, source, "VAPOR TODO", target).plan.match(/(\d+) B font/)![1]; + expect(planFont("gb")).toBe("760"); + // 4bpp GBA and CHR-ROM NES still carry the 3040 B figure. + expect(planFont("gba")).toBe("3040"); + expect(planFont("nes")).toBe("3040"); + }); + test("esp32 uses its 20x18 display grid and emits 1bpp RGB565 data", () => { expect(VAPOR_TARGETS.esp32).toEqual({ name: "esp32", diff --git a/vapor/tests/gb-font.test.ts b/vapor/tests/gb-font.test.ts new file mode 100644 index 000000000..2f9061739 --- /dev/null +++ b/vapor/tests/gb-font.test.ts @@ -0,0 +1,128 @@ +// vapor/tests/gb-font.test.ts — the bytes the GB font actually puts in VRAM. +// +// The compiler emits FONT8 as 1bpp and vapor_gb.c's upload_font() expands it to +// the DMG's 2bpp interleaved tile format, twice: style 0 = ink shade 3 on paper +// 0, style 1 = the inverse. This test does not trust either side. It boots a +// real todo.gb in headless libmgba, reads all 3040 B of tile data the runtime +// wrote at 0x8010, and compares it byte for byte against a reference expansion +// built here from FONT8 and the DMG pixel-format rule alone. +// +// Any bit the expansion drops, duplicates, or inverts wrongly shows up as a +// byte mismatch with its glyph, style, row and plane named. + +import { beforeAll, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { $ } from "bun"; +import { compileVaporApp } from "../compiler/compile.ts"; +import { buildRom } from "../compiler/rom.ts"; +import { FONT8 } from "../compiler/font.gen.ts"; + +const HERE = import.meta.dir; +const ENTRY = join(HERE, "..", "examples", "todo", "todo.tsx"); +const OUT = join(HERE, "..", "..", "dist", "vapor"); +const MGBA_RUNNER = join(HERE, "harness", "mgba_runner"); + +const GLYPHS = 95; +const TILE_BYTES = 16; // 8 rows x 2 planes +const FONT_VRAM = 0x8010; // tile 0 stays blank +const FONT_BYTES = 2 * GLYPHS * TILE_BYTES; // 3040 + +/** DMG 2bpp interleaved, from FONT8 and the hardware rule only: per row, + * byte 0 holds bit 0 of every pixel and byte 1 holds bit 1, MSB leftmost. + * Style 0 paints set pixels shade 3 on shade 0; style 1 swaps them. */ +function referenceFontVram(): number[] { + const out: number[] = []; + for (const [ink, paper] of [ + [3, 0], + [0, 3], + ]) { + for (let g = 0; g < GLYPHS; g++) { + for (let y = 0; y < 8; y++) { + let plane0 = 0; + let plane1 = 0; + for (let x = 0; x < 8; x++) { + const shade = FONT8[g][y] & (0x80 >> x) ? ink : paper; + if (shade & 1) plane0 |= 0x80 >> x; + if (shade & 2) plane1 |= 0x80 >> x; + } + out.push(plane0, plane1); + } + } + } + return out; +} + +/** Names a byte index inside the font region, so a failure reads as a place. */ +function locate(i: number): string { + const style = Math.floor(i / (GLYPHS * TILE_BYTES)); + const within = i % (GLYPHS * TILE_BYTES); + const glyph = Math.floor(within / TILE_BYTES); + const row = Math.floor((within % TILE_BYTES) / 2); + const plane = i % 2; + const ch = String.fromCharCode(0x20 + glyph); + return `style ${style} glyph ${glyph} ('${ch}') row ${row} plane ${plane}`; +} + +let vram: number[] = []; + +beforeAll(async () => { + if (!existsSync(MGBA_RUNNER)) await $`bun ${join(HERE, "harness", "build.ts")}`.quiet(); + const source = await Bun.file(ENTRY).text(); + const app = compileVaporApp(ENTRY, source, "VAPOR TODO", "gb"); + const rom = join(OUT, "gb-font.gb"); + await buildRom(app, "gb", rom); + + // app_init alone spans ~26 video frames on the 1 MHz SM83; upload_font runs + // before it, but hold the same generous margin the parity rig uses. + const scenario = join(OUT, "gb-font-scenario.txt"); + await Bun.write(scenario, `A 90\nD font 0x${FONT_VRAM.toString(16)} ${FONT_BYTES}\n`); + const out = await $`${MGBA_RUNNER} ${rom} ${scenario}`.text(); + const parsed = JSON.parse(out) as { ok: boolean; reads: Record }; + expect(parsed.ok).toBe(true); + const hex = parsed.reads.font; + expect(hex).toHaveLength(FONT_BYTES * 2); + vram = Array.from({ length: FONT_BYTES }, (_, i) => parseInt(hex.slice(i * 2, i * 2 + 2), 16)); +}, 120000); + +test("gb: every font byte in real VRAM matches the reference 2bpp expansion", () => { + const want = referenceFontVram(); + expect(want).toHaveLength(FONT_BYTES); + const mismatches = want + .map((b, i) => (b === vram[i] ? null : `${locate(i)}: got 0x${vram[i].toString(16)} want 0x${b.toString(16)}`)) + .filter((m): m is string => m !== null); + expect(mismatches).toEqual([]); +}); + +test("gb: the uploaded font carries FONT8's bits, both planes and both styles", () => { + // Not a restatement of the reference: these are the three structural facts + // the 1bpp encoding relies on, read off the emulator's VRAM directly. + for (let g = 0; g < GLYPHS; g++) { + for (let y = 0; y < 8; y++) { + const at0 = (g * TILE_BYTES + y * 2) | 0; + const at1 = GLYPHS * TILE_BYTES + at0; + const ch = `glyph ${g} ('${String.fromCharCode(0x20 + g)}') row ${y}`; + // style 0: both planes equal the FONT8 row (shade 3 where a bit is set) + expect(`${ch} p0 ${vram[at0]}`).toBe(`${ch} p0 ${FONT8[g][y]}`); + expect(`${ch} p1 ${vram[at0 + 1]}`).toBe(`${ch} p1 ${FONT8[g][y]}`); + // style 1: the bitwise complement of style 0, in both planes + expect(`${ch} inv p0 ${vram[at1]}`).toBe(`${ch} inv p0 ${(~FONT8[g][y]) & 0xff}`); + expect(`${ch} inv p1 ${vram[at1 + 1]}`).toBe(`${ch} inv p1 ${(~FONT8[g][y]) & 0xff}`); + } + } +}); + +test("gb: tile 0 stays blank and the font region ends where the next tile begins", async () => { + const scenario = join(OUT, "gb-font-edges.txt"); + await Bun.write( + scenario, + ["A 90", "D tile0 0x8000 16", `D after 0x${(FONT_VRAM + FONT_BYTES).toString(16)} 16`].join("\n") + "\n", + ); + const rom = join(OUT, "gb-font.gb"); + const out = await $`${MGBA_RUNNER} ${rom} ${scenario}`.text(); + const parsed = JSON.parse(out) as { ok: boolean; reads: Record }; + expect(parsed.ok).toBe(true); + expect(parsed.reads.tile0).toBe("0".repeat(32)); + // upload_font must not run past its 3040 bytes into the tile after the font. + expect(parsed.reads.after).toBe("0".repeat(32)); +});