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
35 changes: 8 additions & 27 deletions vapor/compiler/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 17 additions & 2 deletions vapor/runtime/gb/vapor_gb.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion vapor/runtime/vapor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions vapor/tests/compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
128 changes: 128 additions & 0 deletions vapor/tests/gb-font.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> };
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<string, string> };
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));
});