From 0607a0401a6de35d1cf45de50ab2852217c416b7 Mon Sep 17 00:00:00 2001 From: liufengkai Date: Mon, 7 Sep 2026 15:28:53 -0700 Subject: [PATCH] perf(vapor): compact GBA font data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GBA target expanded the 760 B 1bpp FONT8 bitmap into a 3040 B 4bpp vp_font_tiles table at compile time, so every ROM carried 2280 B that the runtime can rebuild. Emit the same 1bpp table ESP32 and Playdate already use and expand it into VRAM inside upload_font, which already looped over those halfwords. todo.gba 9356 -> 7160 B (-2196, -23.47%), playdate-six-button.gba 6420 -> 4224 B (-2196, -34.21%), five identical measurement runs per side. The saving reconciles in the symbol table: vp_font_tiles 3040 -> 760 B minus 84 B of expansion code in main. GB, NES, ESP32 and Playdate generated C is byte-identical and their ROMs compare equal. All 3040 bytes reaching VRAM are unchanged, verified by dumping the font charblock out of a booted ROM in headless libmgba rather than by reimplementing the expansion in TS. That dump is the new vapor/tests/gba-font.test.ts, which exists because GBA parity cannot see this surface: its probe is the screenblock at 0x6004000, so it reads tile indices and palette banks, never glyph pixels — with the two halfword stores swapped, parity still passes 6/6 while the new test fails. bun test vapor/tests/: 74 pass / 0 fail (71 baseline + 3), GBA, GB, NES and oracle parity green. --- vapor/compiler/compile.ts | 19 +++++--- vapor/runtime/gba/vapor_gba.c | 19 +++++++- vapor/runtime/vapor.h | 3 +- vapor/tests/gba-font.test.ts | 84 +++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 vapor/tests/gba-font.test.ts diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 752e8e5cf..4be59b4a2 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -2385,8 +2385,9 @@ class AppCompiler { const romStrings = [...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; + /* GBA/ESP32/Playdate ship the 1bpp bitmap and expand at boot; GB and NES + * bake the two glyph styles into the ROM/CHR table. */ + const fontBytes = this.target.name === "gb" || this.target.name === "nes" ? 95 * 32 : 95 * 8; const styleBytes = this.target.name === "gba" ? pairCount * 16 * 2 + pairCount + 3 @@ -2421,8 +2422,14 @@ class AppCompiler { const INK = 1; const PAPER = 2; -/** GBA: 95 glyphs, 4bpp, pixel 1 = ink / 2 = paper (real palette banks). */ -function emitFontGba(): string { +/** The 4bpp GBA tile bytes for the whole font: 95 glyphs x 32 B, pixel 1 = ink + * / 2 = paper (real palette banks), two pixels per byte with the leftmost in + * the low nibble. This is what reaches GBA VRAM, but it is not what ships in + * the ROM: `upload_font()` in vapor/runtime/gba/vapor_gba.c expands the 1bpp + * `vp_font_tiles` into exactly these bytes at boot, so the ROM carries 760 B + * instead of 3040 B. Exported as the reference encoding the expansion is + * tested against; nothing in the compiler emits it. */ +export function gbaFontTileBytes(): number[] { const bytes: number[] = []; for (let g = 0; g < 95; g++) { const bitmap = FONT8[g]; @@ -2434,7 +2441,7 @@ function emitFontGba(): string { } } } - return `const u8 vp_font_tiles[] = { ${bytes.join(",")} };`; + return bytes; } /** Direct 1bpp targets: one byte per 8-pixel row, MSB = leftmost pixel. */ @@ -2513,7 +2520,7 @@ function emitTargetData(target: VaporTarget, styles: StyleTable): string { banks.push(...bank); } return ( - `${emitFontGba()}\n` + + `${emitFont1bpp()}\n` + `const u16 vp_palettes[] = { ${banks.join(",")} };\n` + `const u8 vp_palette_count = ${styles.pairs.length};\n` + `const u16 vp_backdrop = ${rgb555(BACKDROP)};\n` + diff --git a/vapor/runtime/gba/vapor_gba.c b/vapor/runtime/gba/vapor_gba.c index 615f21271..8940e5c47 100644 --- a/vapor/runtime/gba/vapor_gba.c +++ b/vapor/runtime/gba/vapor_gba.c @@ -45,11 +45,26 @@ void *memset(void *dst, int v, unsigned long n) { return dst; } +/* The ROM carries the font 1bpp (95 glyphs x 8 B, MSB = leftmost pixel) and + * BG0 is 4bpp, so each row byte expands to 4 B of tile data with pixel 1 = ink + * and 2 = paper. Expanding here rather than shipping the expanded 3040 B table + * trades one boot loop for 2196 B less ROM in every app. VRAM rejects 8-bit + * writes, so each row goes out as two u16: low pixels first, leftmost pixel in + * the low nibble. */ static void upload_font(void) { - const u16 *src = (const u16 *)vp_font_tiles; volatile u16 *dst = VRAM + 16; /* tile 1; tile 0 stays blank */ u16 i; - for (i = 0; i < 95 * 16; i++) dst[i] = src[i]; + for (i = 0; i < 95 * 8; i++) { + u8 row = vp_font_tiles[i]; + u16 lo = 0, hi = 0; + u8 x; + for (x = 0; x < 4; x++) { + lo |= (u16)((row >> (7 - x)) & 1 ? 1 : 2) << (x * 4); + hi |= (u16)((row >> (3 - x)) & 1 ? 1 : 2) << (x * 4); + } + *dst++ = lo; + *dst++ = hi; + } } static void commit_rows(void) { diff --git a/vapor/runtime/vapor.h b/vapor/runtime/vapor.h index a37c758a9..24f68e6ad 100644 --- a/vapor/runtime/vapor.h +++ b/vapor/runtime/vapor.h @@ -107,7 +107,8 @@ u8 app_flush(void); /* computeds + dirty effects; 1 if painted */ 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 + * GBA: vp_font_tiles 95x8B 1bpp, expanded to 4bpp tiles by upload_font; + * vp_palettes/vp_palette_count/vp_backdrop * GB: vp_font_tiles (2 styles x 95) x 16B 2bpp interleaved * NES: vp_font_tiles (2 styles x 95) x 16B 2bpp planar * ESP32: vp_font_tiles 95x8B 1bpp, direct RGB565 ink/paper tables diff --git a/vapor/tests/gba-font.test.ts b/vapor/tests/gba-font.test.ts new file mode 100644 index 000000000..332e6ca9f --- /dev/null +++ b/vapor/tests/gba-font.test.ts @@ -0,0 +1,84 @@ +// The GBA ROM ships the font 1bpp (760 B) and expands it to 4bpp tiles during +// upload_font(). That trade is only safe if the bytes reaching VRAM are the +// ones the old 3040 B vp_font_tiles table used to carry, so the load-bearing +// assertion here reads the real font charblock out of a booted ROM in mgba and +// compares all 3040 bytes against the reference encoding. The parity suite +// cannot cover this: its GBA rig probes the screenblock at 0x6004000, which +// holds tile indices and palette banks, never glyph pixels. + +import { beforeAll, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { $ } from "bun"; +import { compileVaporApp, gbaFontTileBytes } 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"); + +/** Tile 0 stays blank, so glyph tiles start one 32 B tile into charblock 0. */ +const FONT_VRAM_ADDR = 0x06000020; +const FONT_VRAM_BYTES = 95 * 32; + +let vram: Uint8Array; + +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", "gba"); + const rom = join(OUT, "gba-font.gba"); + await buildRom(app, "gba", rom); + + const scenario = join(OUT, "gba-font.txt"); + await Bun.write(scenario, `A 5\nD font 0x${FONT_VRAM_ADDR.toString(16)} ${FONT_VRAM_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); + vram = Uint8Array.from(Buffer.from(parsed.reads.font, "hex")); +}, 120000); + +describe("gba font ships 1bpp and expands at boot", () => { + test("the emitted table is the 1bpp bitmap, not the 4bpp expansion", async () => { + const source = await Bun.file(ENTRY).text(); + const app = compileVaporApp(ENTRY, source, "VAPOR TODO", "gba"); + const font = app.c.match(/const u8 vp_font_tiles\[\] = \{ ([^}]*) \};/); + expect(font).not.toBeNull(); + const bytes = font![1].split(",").map(Number); + expect(bytes).toHaveLength(95 * 8); + expect(bytes).toEqual(FONT8.flat()); + expect(app.plan).toContain("760 B font"); + }); + + test("upload_font writes every one of the 3040 4bpp bytes the old table held", () => { + const want = gbaFontTileBytes(); + expect(want).toHaveLength(FONT_VRAM_BYTES); + expect(vram).toHaveLength(FONT_VRAM_BYTES); + // Compare as hex so a mismatch names the offset instead of dumping 3040 numbers. + const hex = (a: ArrayLike) => + Array.from(a, (b) => b.toString(16).padStart(2, "0")).join(""); + expect(hex(vram)).toBe(hex(want)); + }); + + test("every expanded pixel is ink(1) or paper(2), matching the glyph bitmap", () => { + // Independent of gbaFontTileBytes: walks FONT8 and checks the nibble that + // upload_font must have produced for each of the 95x8x8 pixels. + for (let g = 0; g < 95; g++) { + for (let y = 0; y < 8; y++) { + const row = FONT8[g][y]; + for (let x = 0; x < 8; x++) { + const at = g * 32 + y * 4 + (x >> 1); + const nibble = x % 2 === 0 ? vram[at] & 0x0f : vram[at] >> 4; + const set = (row & (0x80 >> x)) !== 0; + if (nibble !== (set ? 1 : 2)) { + throw new Error( + `glyph ${g} row ${y} pixel ${x}: bitmap ${set ? "set" : "clear"} but VRAM nibble ${nibble}`, + ); + } + } + } + } + }); +});