Files
deck_in_a_dash/CLAUDE.md
T

16 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project goal

A Pebble smartwatch app that produces a daily personalized tarot (Celtic Cross) and astrology (natal chart + transits) reading. Currently only the core engine exists: a plain-C, dependency-free library plus a standalone CLI binary (dist/deck-engine) that can be built and run without any Pebble SDK or emulator. The watchapp itself (Pebble C/UI, resource packs) has not been started yet — the engine is deliberately built so it can be linked straight into it later without rework (see "Portability to the watch" below).

Build & run

make          # builds dist/{deck-engine,interpreter-cli}, dist/img/, dist/i18n/,
              # dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
make test     # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
make clean    # removes build/ and the generated binaries/img/i18n (never touches dist/user.properties)

A single Makefile at the repo root builds both engine/ and interpreter/ — there is no per-module Makefile, and no cd needed before running make. Build output goes to dist/, a sibling of engine/, interpreter/, and res/. There is no make install; dist/ is meant to be run in place.

Two ways to run a reading:

# 1. Direct CLI, all inputs explicit:
dist/deck-engine --seed "2026-07-03" \
  --birth-date 1990-05-14 --birth-time 14:32 --birth-utc-offset 2 \
  --birth-lat 52.5200 --birth-lon 13.4050 \
  [--date YYYY-MM-DDTHH:MM] [--format text|html|json] [--lang en|de] [--i18n-dir <path>]

# 2. dist/run-engine.sh [text|html|json] [lang] — wraps the binary for
#    daily use: seed and --date come from the OS clock (today's date /
#    current UTC time), birth data + default language are read from
#    dist/user.properties next to the script; the optional [lang] arg
#    overrides that language for a single run.

dist/user.properties is seeded once from engine/scripts/user.properties.template with placeholder values (YOUR_BIRTH_DATE_HERE, etc.) — run-engine.sh/ run-interpreter.sh refuse to run (with a clear error) until the placeholders are replaced with real values. Because dist/ itself is disposable (make clean, or just deleting the directory, wipes it), the scripts Makefile target also keeps a durable backup outside dist/: once dist/user.properties looks filled in (no leftover YOUR_...), every build copies it out to engine/scripts/user.properties.local (gitignored); if dist/user.properties is ever missing when a build runs, it's restored from that backup instead of being reseeded from the placeholder template. dist/run-interpreter.sh is the equivalent daily-use wrapper for the interpreter: it runs run-engine.sh's same OS-clock seed/date and user.properties birth data through deck-engine --format json, piped straight into dist/interpreter-cli — see "Interpretation" below.

Run a single smoke test by editing engine/tests/smoke_test.c's main() temporarily, or just read its assertions — there's no test filter flag, the whole suite runs in milliseconds.

Architecture

deck_in_a_dash/
  res/                          Tarot card art (22 RWS Major Arcana JPEGs),
                                 Waite's "Pictorial Key to the Tarot" (PDF,
                                 public domain — source of all card/spread
                                 text), logo, research notes.
  engine/
    third_party/astronomy/      Vendored cosinekitty/astronomy C library
                                 (MIT, pinned commit — see VENDORED.md).
                                 Unmodified from upstream.
    src/
      rng.h/.c                  Deterministic string-seeded PRNG.
      tarot.h/.c                Celtic Cross spread draw logic.
      tarot_data.c               22 cards' names/meanings + 10 position
                                 names/descriptions (Waite's own text).
      astro.h/.c                 Natal chart + daily transits.
      i18n.h/.c                   key=value translation catalog + English fallback.
      reading.h/.c               Ties tarot + astro together; text/HTML output.
      main.c                     CLI entry point.
    i18n/en.lang, i18n/de.lang   Translation source files, one per language.
    tests/smoke_test.c
    scripts/run-engine.sh, scripts/run-interpreter.sh, scripts/user.properties.template
  interpreter/                  Separate module + binary; see "Interpretation" below.
  dist/                         Build output (gitignored-style; see Build & run).
  Makefile                      Single root Makefile, builds both engine/ and interpreter/.

Data flow / the real API

reading_generate(seed, birth, utc_moment, &DailyReading) in reading.h is the single entry point that matters — it's what the watchapp will call once it exists. It populates a plain struct (NatalChart + DailyTransits + CelticCrossSpread) that the caller walks directly to build a UI. reading_print_text/reading_print_html are desktop-only conveniences for inspecting a reading (used by the CLI); the watchapp will never call them.

Determinism (rng.c)

The Celtic Cross draw must be exactly reproducible from a seed string, on any platform (including the watch's ARM core later), so rng.c is pure integer arithmetic: FNV-1a hashes the seed string, splitmix64 streams from it, and a partial FisherYates shuffle (tarot.c) draws the 10 cards plus a reversal bit per card from that same stream, in one pass.

Known-sharp-edge: rng_next_bounded's rejection-sampling limit must stay a uint64_t. When bound evenly divides 2^32 (true for bound == 2, used by every reversed-card coin flip), the true rejection threshold is 2^32, which silently truncates to 0 in a uint32_t and turns the loop into an infinite one. This exact bug shipped once already — if you touch this function, keep the intermediate type 64-bit.

Astrology (astro.c)

  • Bodies: Sun, Moon, MercuryPluto (Body enum in astro.h, prefixed PLANET_* — deliberately not BODY_*, because Astronomy Engine's own astro_body_t already defines BODY_SUN, BODY_MOON, etc., and both headers are included together in astro.c).
  • Positions must go through Astronomy_GeoVector + Astronomy_Ecliptic, not Astronomy_EclipticLongitude. The latter computes heliocentric longitude and outright rejects BODY_SUN (ASTRO_INVALID_BODY) — wrong for astrology, which needs apparent geocentric position. This also shipped once and was caught by the natal-Sun-sign smoke test.
  • Houses use the Whole Sign system (house 1 = the Ascendant's sign, houses follow zodiacally) — no Placidus/Koch cusp trig. Astronomy Engine has no Ascendant function; it's computed directly in astro.c from sidereal time + a standard low-precision obliquity polynomial + the RAMC/obliquity/latitude identity (Duffett-Smith & Zwart).
  • Every PlanetPosition (astro.h) carries a whole-sign house (1-12), via assign_houses()/whole_sign_house(). DailyTransits.bodies[*].house is always relative to the natal Ascendant, not a fresh "houses for right now" chart — that's the standard, personalized way transits are read (e.g. "transiting Jupiter is in your 5th house"), and it's why astro_compute_daily_transits takes the natal chart as a parameter in the first place (also used for aspect detection).
  • Aspects (conjunction/sextile/square/trine/opposition) use an 8° orb when a luminary (Sun/Moon) is involved, 6° otherwise.

Tarot (tarot.c, tarot_data.c)

Only the 22 Major Arcana are modelled (TAROT_DECK_SIZE) — matches the art actually in res/img/. The 10 Celtic Cross positions (CelticCrossPosition in tarot.h) follow Waite's own original drawing order from The Pictorial Key to the Tarot, Part III §7 ("An Ancient Celtic Method of Divination"): Present → Challenge → Crown → Foundation → Recent Past → Near Future → Attitude → Environment → Hopes/Fears → Outcome. Note this differs from the reordering used by most modern tutorials (which typically put Foundation/Recent Past before Crown) — res/2026_07_03_celtic_cross_spread.txt has an example of that more common (but less original) ordering; don't use it as a reference for the position enum order. Card and position text in tarot_data.c is condensed from Waite's own wording in Part III §3 (public domain).

Translations (i18n.c, engine/i18n/*.lang)

All display text (planet/sign/moon-phase/aspect names, tarot card names and meanings, Celtic Cross position names, and reading.c's section headers) is looked up in a small process-global catalog loaded by i18n_load(path) from a key=value file, via i18n_get(key, fallback). Every call site passes the hardcoded English string as fallback, so a missing file, a missing key, or an untranslated language degrades to English rather than showing a raw key or nothing — this is what lets engine/i18n/de.lang (or any future language) be filled in incrementally.

Keys are namespaced by the slug tables in tarot_data.c/astro.c (card.<slug>.name, card.<slug>.upright/reversed, position.<slug>.name/desc, body.<slug>, sign.<slug>, moonphase.<slug>, aspect.<slug>), plus ui.* keys for reading.c's own labels — deliberately keyed off separate slug strings rather than the C enum names, so renaming an enumerator can never silently break a .lang file. To add a language: copy engine/i18n/en.lang to <code>.lang in the same directory (keys must match exactly) and translate the values — no source changes needed, make picks up any *.lang file there automatically.

i18n_load uses stdio (fopen/fgets), so — like main.c and reading_print_* — it's a desktop-only entry point; i18n_get itself is a pure fixed-size-array lookup with no heap allocation, so it's fine to port to the watch once it has its own (non-file-based) way to populate the catalog, e.g. from a compiled-in resource.

Portability to the watch

rng, tarot, tarot_data, astro, i18n_get, and reading_generate are kept free of stdio/CLI assumptions specifically so they can link into the Pebble watchapp unchanged later. Only reading_print_text/ _html (text formatting), main.c (CLI arg parsing), and i18n_load (reads a file from disk) are desktop-only and won't port as-is.

Interpretation (interpreter/)

A second, separate top-level module and its own binary (dist/interpreter-cli, sibling to dist/deck-engine) that scores how significant a day's transits are — the first piece of a larger "turn the raw reading into an actual narrative" effort described in project chat history, not yet producing any narrative text. deck-engine itself is deliberately unchanged by this beyond gaining --format json; the two binaries compose over that JSON, never by linking together:

make          # -> dist/deck-engine (unchanged; --format json is new) and dist/interpreter-cli
make test     # builds and runs both engine/tests/*_test.c and interpreter/tests/*_test.c

dist/deck-engine ... --format json | dist/interpreter-cli
# or: dist/deck-engine ... --format json > reading.json && dist/interpreter-cli reading.json
# or, for daily use (OS clock + user.properties, same as run-engine.sh):
dist/run-interpreter.sh

The root Makefile builds both from one invocation, but keeps them as separate compilation units throughout — no object file is ever shared between the two binaries (see the next bullet).

  • interpret_daily_reading(reading, &out) (significance.h/.c) scores every aspect in reading->transits.aspects[], keeps the top 5 by score in DailyInterpretation.top_items[] (descending, evicting the rest), and classifies the day into a DaySignificance enum (DAY_QUIET ... DAY_MAJOR) from the single highest-scoring item. interpretation_deserves_framing() is true only at DAY_MAJOR — the intended hook for giving the Celtic Cross reading a "this matters" intro sentence naming top_items[0] on days that earn it; that rendering doesn't exist yet, main.c's report is plain text only.
  • Score = transiting-planet weight (slow/outer planets score higher) × orb tightness (1.0 = exact, ~0 = at the edge) × natal-luminary bonus (transits to natal Sun/Moon score higher than to other planets). The weights and the DAY_QUIET/DAY_NOTABLE/DAY_SIGNIFICANT/DAY_MAJOR thresholds in significance.c are hand-tuned, not derived from anything physical — expect to retune them once real readings are compared against how "major" a day actually feels.
  • reading_print_json (engine/src/reading.c) is the wire format: the full DailyReading (natal, transits, spread), using the language-independent astro_*_slug()/tarot_*_slug() accessors (astro.h/tarot.h) rather than astro_*_name()/tarot_*_name() — the JSON must stay identical regardless of --lang, since it's a machine interchange format, not display text.
  • json.c/json.h is a small hand-rolled recursive-descent JSON parser (object/array/string/number/bool/null) - not a general-purpose validating library, just enough to parse deck-engine's own output. Unlike the core engine, it uses malloc; interpreter-cli was never meant to run on the watch itself.
  • reading_io.c walks the parsed JSON down to "transits"."aspects" and fills a DailyReading with those (used for scoring), plus "natal"."bodies"/"transits"."bodies" (each body's sign + whole-sign house, via parse_bodies() — used only for the sign/house context main.c prints alongside each significant event, never for scoring). "spread" is left zeroed entirely - nothing reads it. An aspect naming a planet/aspect-type slug it doesn't recognize, or a document missing "transits"."aspects" entirely (as opposed to a present-but-empty array, which is a valid "no aspects today"), makes the whole load fail; by contrast an unrecognized body slug or missing house inside "bodies" is silently skipped (that body's house stays 0, an invalid whole-sign house number used as "no data" - main.c checks for it before printing sign/house), since that's supplementary display context rather than something scoring depends on.
  • Deliberately header-only dependency on the engine, throughout: significance.h/reading_io.h #include engine/src/reading.h for the struct/enum definitions, but the root Makefile never compiles or links any engine .c file (or the vendored Astronomy Engine) into the interpreter's binary or tests — every test in interpreter/tests/ runs against hand-built JSON text or hand-built DailyReading values, with no real ephemeris/tarot-draw call involved anywhere. Consequently reading_io.c (planet/aspect/sign slugs) and main.c (planet/aspect/sign display names) each duplicate a small local table rather than linking astro.c to reuse its own - keep those in sync if the engine's slugs/names ever change.
  • Only Aspects compete for the top 5 so far (SignificantItemKind is currently just ITEM_ASPECT); moon phase and house ingresses are candidate future item kinds but aren't scored yet.

Licensing

  • Engine code (engine/src/, engine/scripts/) and the root Makefile: MIT, per the repo's top-level LICENSE.
  • engine/third_party/astronomy/: vendored MIT code, unmodified — see VENDORED.md for the pinned upstream commit.
  • Card/spread text in tarot_data.c/engine/i18n/en.lang: condensed from A. E. Waite's The Pictorial Key to the Tarot (1911), public domain.
  • engine/i18n/de.lang's card/spread text is an original translation of that same public-domain English text, made for this project — not taken from any specific published German edition.