Introduces watch/, a real Pebble watchapp (built via pebble build) that
build / build (push) Successful in 30s

shows the daily tarot/astrology reading on-device: persistence, reading
computation, and UI screens, driven by the existing engine/interpreter
code linked in unchanged where possible.

Required engine-side changes to make that linking work:
- A compact low-precision ephemeris (lowprec_ephemeris.c) replacing the
  vendored ~127KB Astronomy Engine, which doesn't fit the watch's ~64KB
  app budget.
- Hand-rolled sqrt/atan2/sin/cos replacements for that ephemeris and
  astro.c's Ascendant calculation - Pebble's statically-linked libm
  hard-faults on real hardware under this app's -fPIE link for all four.
- narrative.c/guidance.c refactored from FILE*/fprintf onto snprintf-
  based buffers, since Pebble's SDK blocks fprintf at compile time.
This commit is contained in:
ml
2026-07-14 17:10:22 +02:00
parent d9b6e2889b
commit 1a654da3e9
44 changed files with 1920 additions and 182 deletions
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Downsizes res/img/*.jpeg into watch/resources/img/*.png for Pebble.
The 22 Major Arcana JPEGs in res/img/ are ~1MB/~1086x1810px each -
suitable for the desktop HTML reading (an <img> tag pointed at the file
as-is) but far too large for a Pebble app's own resource budget (256KB
total on basalt-class platforms, shared with every other resource this
app ships). This script resizes each to CARD_WIDTH px wide (preserving
aspect ratio) and writes a PNG per card, named after tarot_data.c's own
k_card_slug table so watch/package.json's "resources.media" entries
(IMG_CARD_<SLUG UPPERCASE>, file img/<slug>.png) line up without a
separate mapping table anywhere.
CARD_WIDTH=72 (-> 72x120) was picked empirically: a real `pebble build`
of two sample cards at this size measured ~5KB/card on color platforms
(basalt/chalk/emery/gabbro) and ~2.9KB/card on the black-and-white
diorite/flint - so all 22 cards together cost roughly 111KB/64KB
respectively, comfortably inside the 256KB resource budget with room
left for other future resources (fonts, icons), while still rendering
at close to a full basalt-class screen's height (120px of 168px).
Generated output, like i18n_tables.auto.c - not committed (see
.gitignore), regenerate by running this script directly or via the root
Makefile's `make watchapp` (task/target that also runs `pebble build`).
Requires Pillow (`pip install Pillow`), not part of this repo's own
desktop build/test toolchain.
"""
import os
import sys
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "res", "img"))
OUT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "resources", "img"))
CARD_WIDTH = 72
# (source file in res/img/, slug matching engine/src/tarot_data.c's
# k_card_slug table) - in TarotCard enum order (CARD_FOOL = 0 ... CARD_WORLD).
CARDS = [
("RWS_Tarot_00_Fool.jpeg", "fool"),
("RWS_Tarot_01_Magician.jpeg", "magician"),
("RWS_Tarot_02_High_Priestess.jpeg", "high_priestess"),
("RWS_Tarot_03_Empress.jpeg", "empress"),
("RWS_Tarot_04_Emperor.jpeg", "emperor"),
("RWS_Tarot_05_Hierophant.jpeg", "hierophant"),
("RWS_Tarot_06_Lovers.jpeg", "lovers"),
("RWS_Tarot_07_Chariot.jpeg", "chariot"),
("RWS_Tarot_08_Strength.jpeg", "strength"),
("RWS_Tarot_09_Hermit.jpeg", "hermit"),
("RWS_Tarot_10_Wheel_of_Fortune.jpeg", "wheel_of_fortune"),
("RWS_Tarot_11_Justice.jpeg", "justice"),
("RWS_Tarot_12_Hanged_Man.jpeg", "hanged_man"),
("RWS_Tarot_13_Death.jpeg", "death"),
("RWS_Tarot_14_Temperance.jpeg", "temperance"),
("RWS_Tarot_15_Devil.jpeg", "devil"),
("RWS_Tarot_16_Tower.jpeg", "tower"),
("RWS_Tarot_17_Star.jpeg", "star"),
("RWS_Tarot_18_Moon.jpeg", "moon"),
("RWS_Tarot_19_Sun.jpeg", "sun"),
("RWS_Tarot_20_Judgement.jpeg", "judgement"),
("RWS_Tarot_21_World.jpeg", "world"),
]
def main():
os.makedirs(OUT_DIR, exist_ok=True)
written = 0
for src_name, slug in CARDS:
src_path = os.path.join(SRC_DIR, src_name)
out_path = os.path.join(OUT_DIR, f"{slug}.png")
# wscript runs this on every `pebble build` (like gen_i18n_tables.py)
# so a full 22-image resize must be cheap on the common no-op case -
# skip any card whose output is already newer than its source JPEG.
if os.path.exists(out_path) and os.path.getmtime(out_path) >= os.path.getmtime(src_path):
continue
with Image.open(src_path) as img:
ratio = CARD_WIDTH / img.width
height = round(img.height * ratio)
resized = img.convert("RGB").resize((CARD_WIDTH, height), Image.LANCZOS)
resized.save(out_path, "PNG", optimize=True)
written += 1
print(f"wrote {written}/{len(CARDS)} card images to {OUT_DIR}", file=sys.stderr)
if __name__ == "__main__":
main()
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Generates watch/src/c/i18n_tables.auto.c from engine/i18n/*.lang.
Compiles each non-English .lang file into a `static const char *const`
keys[]/values[] pair for i18n_load_table() (see engine/src/i18n.h's own
doc comment on why the watch needs a zero-copy, compiled-in table
instead of i18n_load()'s file-based parsing). English is deliberately
skipped: the fallback text already baked into tarot_data.c/astro.c/
narrative.c/guidance.c *is* English, so shipping a redundant English
catalog would roughly double this app's translation-data footprint
(which counts against the same ~64KB whole-app budget as everything
else - see watch/README.md) for zero behavioural benefit.
This is a generated file (like message_keys.auto.c, resource_ids.auto.c
elsewhere in a Pebble project) - not meant to be hand-edited or
committed; watch/wscript regenerates it on every build. Parsing rules
mirror engine/src/i18n.c's i18n_load() exactly: '#'-prefixed and blank
lines are skipped, the first '=' splits key/value, and both sides are
trimmed of surrounding spaces/tabs only (not quotes - values are raw
text, same as the desktop file loader).
"""
import glob
import os
import sys
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
I18N_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "engine", "i18n"))
OUT_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "src", "c", "i18n_tables.auto.c"))
def parse_lang_file(path):
entries = []
with open(path, "r", encoding="utf-8") as f:
for raw_line in f:
line = raw_line.rstrip("\r\n")
stripped = line.strip(" \t")
if not stripped or stripped.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip(" \t")
value = value.strip(" \t")
if not key:
continue
entries.append((key, value))
return entries
def c_string_literal(s):
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
return '"' + escaped + '"'
def main():
lang_files = sorted(glob.glob(os.path.join(I18N_DIR, "*.lang")))
languages = []
for path in lang_files:
code = os.path.splitext(os.path.basename(path))[0]
if code == "en":
continue # see module doc comment - English is never a compiled table
languages.append((code, parse_lang_file(path)))
lines = []
lines.append("/* Generated by watch/scripts/gen_i18n_tables.py from engine/i18n's .lang")
lines.append(" * files - do not edit by hand, and do not commit (see .gitignore). */")
lines.append("")
lines.append('#include "i18n_tables.h"')
lines.append("")
lines.append('#include "../../../engine/src/i18n.h"')
lines.append("")
lines.append("#include <stddef.h>")
lines.append("#include <string.h>")
lines.append("")
for code, entries in languages:
lines.append(f"static const char *const k_keys_{code}[] = {{")
for key, _ in entries:
lines.append(f" {c_string_literal(key)},")
lines.append("};")
lines.append(f"static const char *const k_values_{code}[] = {{")
for _, value in entries:
lines.append(f" {c_string_literal(value)},")
lines.append("};")
lines.append("")
lines.append("void watch_i18n_load(const char *lang_code) {")
for code, entries in languages:
lines.append(f' if (lang_code && strcmp(lang_code, "{code}") == 0) {{')
lines.append(f" i18n_load_table(k_keys_{code}, k_values_{code}, {len(entries)});")
lines.append(" return;")
lines.append(" }")
lines.append(" i18n_load_table(NULL, NULL, 0); /* \"en\" or unrecognized: use built-in English */")
lines.append("}")
lines.append("")
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
with open(OUT_PATH, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"wrote {OUT_PATH} ({', '.join(code for code, _ in languages)})", file=sys.stderr)
if __name__ == "__main__":
main()