Skip to content
Open
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
71 changes: 66 additions & 5 deletions simulator/badge_simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import json
import math
import os
import struct
import sys
import traceback
from types import ModuleType
Expand Down Expand Up @@ -587,6 +588,64 @@ def xor(r, g=None, b=None, a=255) -> tuple:
return brushes.color(r, g, b, a)


class _PPFFont:
"""Native PPF pixel font renderer."""
def __init__(self, path):
with open(path, "rb") as f:
self._data = f.read()
self._glyph_count = struct.unpack_from(">I", self._data, 6)[0]
self._bbox_w = struct.unpack_from(">H", self._data, 10)[0]
self._bbox_h = struct.unpack_from(">H", self._data, 12)[0]
self._row_bytes = (self._bbox_w + 7) // 8
self._glyph_data_size = self._row_bytes * self._bbox_h
self._table_start = 46
self._bitmap_start = self._table_start + self._glyph_count * 6
self._glyphs = {}
for i in range(self._glyph_count):
off = self._table_start + i * 6
cp = struct.unpack_from(">I", self._data, off)[0]
w = struct.unpack_from(">H", self._data, off + 4)[0]
Comment on lines +596 to +607

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_PPFFont.init parses fixed offsets (glyph_count/bbox/table_start) without validating the file signature or ensuring the computed table/bitmap ranges are within the file length. If a .ppf is truncated/corrupted, it may initialize successfully but later crash in render() with IndexError. Consider validating the magic (e.g., b"ppf!") and bounds-checking (len(_data) >= bitmap_start + glyph_count * glyph_data_size) and raising a clear exception so PixelFont.load() can safely fall back.

Suggested change
self._glyph_count = struct.unpack_from(">I", self._data, 6)[0]
self._bbox_w = struct.unpack_from(">H", self._data, 10)[0]
self._bbox_h = struct.unpack_from(">H", self._data, 12)[0]
self._row_bytes = (self._bbox_w + 7) // 8
self._glyph_data_size = self._row_bytes * self._bbox_h
self._table_start = 46
self._bitmap_start = self._table_start + self._glyph_count * 6
self._glyphs = {}
for i in range(self._glyph_count):
off = self._table_start + i * 6
cp = struct.unpack_from(">I", self._data, off)[0]
w = struct.unpack_from(">H", self._data, off + 4)[0]
data = self._data
data_len = len(data)
# Basic sanity checks: header size and magic signature.
# The PPF format used here is expected to start with b"ppf!".
if data_len < 16:
raise ValueError("PPF font file too short: %r" % (path,))
if data[:4] != b"ppf!":
raise ValueError("Invalid PPF font signature in %r" % (path,))
# Parse header values from fixed offsets.
self._glyph_count = struct.unpack_from(">I", data, 6)[0]
self._bbox_w = struct.unpack_from(">H", data, 10)[0]
self._bbox_h = struct.unpack_from(">H", data, 12)[0]
# Sanity-check bounding box dimensions.
if self._bbox_w <= 0 or self._bbox_h <= 0:
raise ValueError("Invalid PPF font metrics in %r" % (path,))
self._row_bytes = (self._bbox_w + 7) // 8
self._glyph_data_size = self._row_bytes * self._bbox_h
self._table_start = 46
self._bitmap_start = self._table_start + self._glyph_count * 6
# Ensure glyph table and bitmap data both fit within the file.
glyph_table_end = self._table_start + self._glyph_count * 6
if glyph_table_end > data_len:
raise ValueError("Corrupt PPF font (truncated glyph table): %r" % (path,))
bitmap_end = self._bitmap_start + self._glyph_count * self._glyph_data_size
if bitmap_end > data_len:
raise ValueError("Corrupt PPF font (truncated bitmap data): %r" % (path,))
self._glyphs = {}
for i in range(self._glyph_count):
off = self._table_start + i * 6
cp = struct.unpack_from(">I", data, off)[0]
w = struct.unpack_from(">H", data, off + 4)[0]

Copilot uses AI. Check for mistakes.
self._glyphs[cp] = (i, w)

def get_height(self):
return self._bbox_h

def size(self, text):
text = str(text)
w = 0
space_w = self._bbox_w // 3
for ch in text:
cp = ord(ch)
if cp == 32:
w += space_w + 1
elif cp in self._glyphs:
w += self._glyphs[cp][1] + 1
return (max(0, w - 1), self._bbox_h)
Comment on lines +615 to +623

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Space width is hard-coded as bbox_w // 3, even though PPF fonts commonly include an explicit glyph-table entry for U+0020 (space). This can make spacing differ from the actual font metrics. Prefer using the glyph-table width for codepoint 32 when present (and only fall back to bbox_w // 3 when it is not).

Copilot uses AI. Check for mistakes.

def render(self, text, antialias, color, *args, **kwargs):
text = str(text)
tw, th = self.size(text)
surf = pygame.Surface((max(1, tw), th), pygame.SRCALPHA)

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

render() guards against zero-width surfaces via max(1, tw) but not zero height; if bbox_h is ever 0 (bad/corrupt font) pygame.Surface((..., 0)) will raise. Consider using max(1, th) as well (and/or validating bbox_h > 0 during init).

Suggested change
surf = pygame.Surface((max(1, tw), th), pygame.SRCALPHA)
surf = pygame.Surface((max(1, tw), max(1, th)), pygame.SRCALPHA)

Copilot uses AI. Check for mistakes.
space_w = self._bbox_w // 3
x = 0
for ch in text:
cp = ord(ch)
if cp == 32:
x += space_w + 1
continue
if cp not in self._glyphs:
continue
Comment on lines +617 to +637

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For characters not present in the glyph table, both size() and render() currently skip without advancing x/w. That causes subsequent glyphs to overlap and size() to under-report width for strings containing unknown codepoints. Consider advancing by a fallback width (e.g., space width or bbox_w) and/or rendering a replacement glyph (like '?') to preserve layout.

Copilot uses AI. Check for mistakes.
idx, gw = self._glyphs[cp]
bmp_off = self._bitmap_start + idx * self._glyph_data_size
for row in range(self._bbox_h):
for col in range(gw):
byte_idx = bmp_off + row * self._row_bytes + (col >> 3)
if self._data[byte_idx] & (1 << (7 - (col & 7))):
surf.set_at((x + col, row), color)
x += gw + 1
Comment on lines +625 to +645

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_PPFFont.render() uses nested loops with surf.set_at() per pixel; in pygame this is very slow and can become a bottleneck when drawing lots of text each frame. At minimum, lock the surface during pixel writes (surf.lock()/unlock()), or switch to a faster bulk approach (PixelArray/surfarray) to keep simulator FPS stable.

Copilot uses AI. Check for mistakes.
return surf


class PixelFont:
class _Wrapper:
__slots__ = ("_font", "name", "height")
Expand Down Expand Up @@ -615,15 +674,17 @@ def load(path: str, size: int = 14):
font = None
if os.path.exists(resolved):
ext = os.path.splitext(resolved)[1].lower()
if ext in {".ttf", ".otf", ".ttc"}:
if ext == ".ppf":
try:
font = _PPFFont(resolved)
except Exception as e:
print(f"PPF load failed for {resolved}: {e}")
font = None
elif ext in {".ttf", ".otf", ".ttc"}:
try:
font = pygame.font.Font(resolved, size)
except Exception:
font = None
else:
# Pico pixel fonts (`.ppf`) aren't true TTF files; using the
# default pygame font keeps the simulator stable.
font = None
if font is None:
font = pygame.font.Font(None, size)

Expand Down
Loading