Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions vapor/compiler/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand All @@ -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. */
Expand Down Expand Up @@ -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` +
Expand Down
19 changes: 17 additions & 2 deletions vapor/runtime/gba/vapor_gba.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion vapor/runtime/vapor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions vapor/tests/gba-font.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> };
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<number>) =>
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}`,
);
}
}
}
}
});
});