8 Commits

Author SHA1 Message Date
ml ff2b43f1e0 Add a Docker-based build image and wire it into CI for the watch app
build / build (push) Failing after 0s
2026-07-18 07:52:40 +02:00
ml 4e83ebbf50 Add daily notification wakeups and a make watchapp packaging target
build / build (push) Successful in 20s
Schedules a one-shot Pebble wakeup for the configured notification
time, re-arming it on every launch and config change, and vibrates/
refreshes the report when it fires (foreground or fresh launch).
Also adds a `make watchapp` target that builds the watchapp via the
Pebble SDK and copies a versioned .pbw to dist/, matching `make
package`'s version resolution — deliberately not wired into CI, which
has no Pebble SDK installed.
2026-07-17 20:48:31 +02:00
ml 7a77c4a07d Adding gender to the config
build / build (push) Successful in 19s
2026-07-16 22:49:06 +02:00
ml 1ebbfebe31 Add watch/phone icons and logo, reorder the interpretation report, and stamp every report with the day it's for
build / build (push) Successful in 30s
2026-07-16 18:54:32 +02:00
ml b94e76931a Replace the watch app's menu UI with a single scrollable report page, add the config page
build / build (push) Successful in 18s
Report screen (ui_report_window.c, new): replaces the MenuLayer +
per-item drill-down windows (ui_menu_window.c/h, ui_card_window.c/h,
removed) with one continuously scrollable page mirroring the
interpreter's own text/HTML report - day significance, each transit's
narrative, the full Celtic Cross spread, and guidance last. Bigger/
bolder fonts throughout. Card images are a fixed 72px wide: a full-
display-width version was tried first but reliably crashes the real
PNG decoder ("PNG memory allocation failed") on actual hardware,
independent of the app's own heap or lazy-loading - 72px is the
hardware-verified ceiling.

Config page (pkjs/index.js): a self-contained `data:` URI settings form
for birth data/language/notification, submitting via AppMessage to the
existing app_message.c receiver. package.json needed
"capabilities": ["configurable"] for the Pebble mobile app to expose a
Settings entry for the sideloaded app at all - app_message.c's own
receiving logic was already correct. Added config_log() (config.c) to
confirm on-device, via APP_LOG, exactly which values a shown reading is
based on - both right after a config submission and at every app
launch.
2026-07-14 19:03:25 +02:00
ml 1a654da3e9 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.
2026-07-14 17:10:22 +02:00
ml d9b6e2889b Statically linking the binaries
build / build (push) Successful in 17s
2026-07-11 21:10:05 +02:00
ml 83248561cc Adding gitea action
build / build (push) Successful in 33s
2026-07-11 20:23:39 +02:00
69 changed files with 3575 additions and 337 deletions
+84
View File
@@ -0,0 +1,84 @@
name: build
# Build a versioned Linux CLI release tarball (see `make package`) and the
# Pebble watchapp (see `make watchapp`) on every push/PR, plus on-demand
# via the Gitea "Run workflow" button. Runs inside build-image (see
# ../../build-image/Dockerfile, built/pushed via ../../build-image.sh and
# ../../upload-image.sh), which bundles the Pebble SDK alongside the C
# toolchain.
on:
push:
pull_request:
workflow_dispatch:
jobs:
build:
# Runner defaults `run:` steps to `sh`, which doesn't understand
# `set -o pipefail` used below — force bash explicitly.
defaults:
run:
shell: bash
# Must match a label your act_runner is registered with.
runs-on: ubuntu-latest
container:
image: cr.ladkau.de/deck-in-a-dash/builder:latest
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- uses: actions/checkout@v4
with:
# `make package`/`make watchapp`'s version comes from `git
# describe --tags` - needs full history/tags, not
# actions/checkout's default shallow single-commit clone.
fetch-depth: 0
- name: Preflight
run: |
set -euo pipefail
command -v cc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: no C compiler (cc) in PATH" >&2; exit 1; }
command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; }
command -v pebble >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: pebble not in PATH" >&2; exit 1; }
- name: Build and test
run: |
set -euo pipefail
make test
make package
make watchapp
- name: Upload build artifact
# v4 uses the newer @actions/artifact backend, which this Gitea
# instance's artifact storage doesn't support (GHESNotSupportedError) — v3 works.
uses: actions/upload-artifact@v3
with:
name: deck-in-a-dash
path: dist/deck-in-a-dash-*-linux-*.tar.gz
- name: Upload watchapp artifact
uses: actions/upload-artifact@v3
with:
name: deck-in-a-dash-watchapp
path: dist/deck-in-a-dash-*.pbw
- name: Publish to dl.ladkau.de
# Uploads the tarball and .pbw over SFTP instead of using
# actions/upload-artifact (whose zip wrapping can't be disabled).
# Only runs on push so PR builds don't publish.
if: gitea.event_name == 'push'
run: |
set -euo pipefail
TARBALL="$(ls dist/deck-in-a-dash-*-linux-*.tar.gz)"
PBW="$(ls dist/deck-in-a-dash-*.pbw)"
mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
-o StrictHostKeyChecking=accept-new \
uploader@dl.ladkau.de <<EOF
-mkdir files/deck-in-a-dash
put $TARBALL files/deck-in-a-dash/$(basename "$TARBALL")
put $PBW files/deck-in-a-dash/$(basename "$PBW")
EOF
+8
View File
@@ -2,6 +2,10 @@
# data once filled in — never commit it.
/dist
/build
/watch/build
/watch/src/c/i18n_tables.auto.c
/watch/resources/img
/watch/resources/app_icon.png
# Durable backup of the same real birth data, outside dist/ so a dist/
# wipe doesn't lose it — see the Makefile's `scripts` target. Never
@@ -13,3 +17,7 @@ core
core.*
.DS_Store
# Container registry credentials for build-image.sh/run-image.sh/
# upload-image.sh — never commit; copy registry.env.example instead.
/registry.env
+85 -3
View File
@@ -20,6 +20,9 @@ make # builds dist/{deck-engine,interpreter-cli}, dist/img/, dist/i18n/
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)
make package # builds a versioned Linux CLI tarball at dist/ - see "Packaging" below
make watchapp # builds the Pebble watchapp and copies a versioned .pbw to
# dist/ - see "Packaging" below. Requires the Pebble SDK
# (not needed for anything else above); not run by CI.
```
A single `Makefile` at the repo root builds both `engine/` and
@@ -94,6 +97,57 @@ end up in a distributable package. The package always gets the
placeholder template instead, identical to what a first-time `make`
seeds `dist/user.properties` with.
**CI** (`.gitea/workflows/build.yml`): runs `make test && make package`
on every push/PR (plus manual dispatch). The resulting tarball is both
kept as a Gitea Actions run artifact and, on `push` only (not PRs),
published over SFTP to `dl.ladkau.de` under `files/deck-in-a-dash/` -
requires a `DL_SFTP_KEY` secret configured on this repo (or inherited
from the org/instance level).
### Watch app packaging (`make watchapp`)
A separate target, deliberately **not** a prerequisite of `all` - it
shells out to `pebble build` inside `watch/` (via `watch/wscript`'s own
Pebble SDK/waf tooling, not this Makefile's plain-`cc` rules) and copies
the resulting `watch/build/watch.pbw` to
`dist/deck-in-a-dash-<version>.pbw`, using the exact same version
resolution as `make package` above (current git tag, or `make watchapp
VERSION=1.2.3` to override - see that section, not duplicated here).
Requires the Pebble SDK (`pebble` on `PATH`, plus the Python 3 + Pillow
`watch/wscript` needs for its resource-generation scripts) - a machine
without it can still build/test/package the CLI via every other target
in this file.
`.gitea/workflows/build.yml` **does** run `make watchapp` on every
push/PR, alongside `make test`/`make package` - the whole job runs
inside a container image (`build-image/`, built and pushed with
`build-image.sh`/`upload-image.sh`, see "Build image" below) that has
the Pebble SDK baked in, so the CI runner itself needs no toolchain
beyond Docker access to pull that image. Both the CLI tarball and the
`.pbw` are uploaded as build artifacts and published to
`dl.ladkau.de/files/deck-in-a-dash/` on push.
### Build image
`build-image/Dockerfile` pins the exact toolchain versions (Pebble
Tool, Pebble SDK core, Node.js) needed for `make test`, `make package`,
and `make watchapp`, so builds are reproducible independent of whatever
happens to be installed on a given machine. Three root-level scripts
drive it, each reading registry settings from a gitignored
`registry.env` (copy `registry.env.example` to create it - never commit
the real file, since it holds registry credentials) and the image tag
from `build-image/VERSION`:
- `build-image.sh` - builds and locally tags the image (`:VERSION` and
`:latest`), no push.
- `run-image.sh [VERSION]` - runs `make test package watchapp` inside
the locally-built image against a live bind-mount of the repo, wiping
generated build artifacts first for CI parity. Use this to validate a
`build-image/Dockerfile` change before pushing the image anywhere.
- `upload-image.sh` - logs in and pushes the image (`:VERSION` and
`:latest`) to the registry; `.gitea/workflows/build.yml` pulls
`:latest` to run its build job (see "Watch app packaging" above).
## Architecture
```
@@ -193,6 +247,23 @@ 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).
**Gender** (`Gender` in `tarot.h`: `GENDER_UNSPECIFIED`/`GENDER_MALE`/
`GENDER_FEMALE`) is who the reading is for — it only affects the
pronouns in the 9 Celtic Cross position names/descriptions that have one
(Waite addresses the querent as "him" throughout; every other position
text in this codebase — card meanings, narrative, guidance — is already
pronoun-neutral or direct "you" address, so nothing else varies with
it). `DailyReading.gender` is set by `reading_generate()` the same way
`utc_moment` is, `deck-engine`'s `--gender male|female|unspecified`
flag/`dist/user.properties`' `gender=` field/the watch config page's
Gender field all feed it, and `reading_print_json`/`reading_load_json`
carry it across the `deck-engine``interpreter-cli` JSON boundary
(`tarot_gender_slug()`) the same way `date` does. `GENDER_UNSPECIFIED`
is the default and renders pronoun-neutral phrasing (singular "they" in
English; German rephrases around a noun like "die fragende Person"
rather than a pronoun, since German has no equivalent singular-they
construction in common use).
### Translations (`i18n.c`, `engine/i18n/*.lang`)
All display text (planet/sign/moon-phase/aspect names, tarot card names
@@ -210,7 +281,12 @@ Keys are namespaced by the *slug* tables in `tarot_data.c`/`astro.c`
`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
`.lang` file. `position.<slug>.name/desc` additionally take an optional
`.male`/`.female` suffix (e.g. `position.attitude.name.male`) for the
`Gender`-varying wording (see "Tarot" above); the unsuffixed key is
looked up first as the fallback for a missing gendered key, so a
position whose wording doesn't vary by gender only needs the one
unsuffixed key. **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.
@@ -437,10 +513,16 @@ built once and linked into *both* binaries.
`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.
domain. The `GENDER_MALE` variant of the Celtic Cross position text
keeps Waite's own "him"/"his" wording verbatim; the
`GENDER_UNSPECIFIED`/`GENDER_FEMALE` variants are original paraphrases
written for this project, not Waite's own text (see "Tarot" above).
- `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.
taken from any specific published German edition. Its `Gender`-varying
position text follows the same policy as the English original: `.male`
is a direct translation of Waite's own wording, `.female`/unsuffixed
are original paraphrases for this project.
- Transit narrative text in `interpreter/src/narrative.c`: condensed from
Sepharial's *Transits and Planetary Periods* (1920, public domain —
`res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto
+49 -8
View File
@@ -1,5 +1,12 @@
CC ?= cc
CFLAGS ?= -std=c99 -Wall -Wextra -O2
# Static by default so dist/deck-engine and dist/interpreter-cli (and the
# `package` tarball built from them) don't depend on the build machine's
# glibc version at runtime - a binary linked against a newer glibc than a
# user's system has refuses to run at all ("GLIBC_2.3x not found"), which
# is exactly the kind of thing a downloaded release package must not hit.
# Override with `LDFLAGS= make` if static libc/libm aren't available.
LDFLAGS ?= -static
LDLIBS := -lm
BUILD_DIR := build
@@ -80,17 +87,22 @@ GUIDANCE_TEST_BIN := $(BUILD_DIR)/guidance_test
ARCH := $(shell uname -m)
PACKAGE_DIR := $(BUILD_DIR)/package
.PHONY: all test clean images i18n scripts package
# ---- watch app (Pebble) ----
WATCH_DIR := watch
WATCH_PBW := $(WATCH_DIR)/build/watch.pbw
.PHONY: all test clean images i18n scripts package watchapp
all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts
$(ENGINE_BINARY): $(ENGINE_OBJS) $(ENGINE_MAIN_OBJ)
@mkdir -p $(DIST_DIR)
$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(LDLIBS)
$(INTERP_BINARY): $(INTERP_LIB_OBJS) $(INTERP_MAIN_OBJ) $(INTERP_TAROT_TEXT_OBJS)
@mkdir -p $(DIST_DIR)
$(CC) $(CFLAGS) -o $@ $^
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
# The HTML report expects card art at img/<file> next to deck-engine, so
# every build refreshes a copy alongside it (cheap: 22 small JPEGs).
@@ -137,20 +149,20 @@ test: $(ENGINE_TEST_BINARY) $(SIGNIFICANCE_TEST_BIN) $(JSON_TEST_BIN) $(NARRATIV
./$(GUIDANCE_TEST_BIN)
$(ENGINE_TEST_BINARY): $(ENGINE_OBJS) $(ENGINE_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(LDLIBS)
$(SIGNIFICANCE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/significance.o $(SIGNIFICANCE_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
$(JSON_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/json.o $(OBJ_DIR)/$(INTERP_SRC_DIR)/reading_io.o $(JSON_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
$(NARRATIVE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/narrative.o $(INTERP_TAROT_TEXT_OBJS) $(NARRATIVE_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
$(GUIDANCE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/guidance.o $(OBJ_DIR)/$(INTERP_SRC_DIR)/significance.o \
$(INTERP_TAROT_TEXT_OBJS) $(GUIDANCE_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
$(OBJ_DIR)/%.o: %.c
@mkdir -p $(dir $@)
@@ -201,3 +213,32 @@ clean:
rm -rf $(BUILD_DIR) $(ENGINE_BINARY) $(INTERP_BINARY) $(DIST_IMG_DIR) $(DIST_I18N_DIR) \
$(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh
# user.properties holds the user's own data - never removed by clean.
# Builds the Pebble watchapp (`pebble build` in watch/, via its own
# wscript/pebble_sdk waf tooling - not a plain C toolchain, so this is a
# separate target from `all` rather than one of its prerequisites) and
# copies the resulting .pbw into dist/, versioned the same way `package`
# above versions the CLI tarball (current git tag, or
# `make watchapp VERSION=1.2.3` to override). Requires the Pebble SDK
# (`pebble` on PATH, plus Python 3 + Pillow for watch/scripts/*.py - see
# watch/wscript) - this is NOT checked by the root `make`/`make test`,
# so a machine without the Pebble SDK can still build/test the CLI.
watchapp:
cd $(WATCH_DIR) && pebble build
@mkdir -p $(DIST_DIR)
@V="$(VERSION)"; \
if [ -z "$$V" ]; then \
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
V=$${TAG#v}; \
else \
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
fi; \
fi; \
case "$$V" in \
[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
esac; \
PBW_NAME="deck-in-a-dash-$$V.pbw"; \
cp $(WATCH_PBW) "$(DIST_DIR)/$$PBW_NAME"; \
echo "Wrote $(DIST_DIR)/$$PBW_NAME"
+34
View File
@@ -52,7 +52,18 @@ interpreter/ Separate module + binary (dist/interpreter-cli)
deck-engine's --format json output. Supports
--format text|html|json and --lang en|de for
its own output too (see CLAUDE.md).
watch/ The actual Pebble watchapp - links engine/src/
and interpreter/src/ in directly (see CLAUDE.md's
"Portability to the watch"), plus its own
persistence, reading recompute, UI, daily
notification, and config page. Built separately
from the rest of this repo (`make watchapp`,
below) - requires the Pebble SDK.
docs/ Architecture/dataflow diagrams, input/output format reference.
build-image/ Dockerfile for the reproducible build environment
(C toolchain + Pebble SDK) used by CI and by the
build-image.sh/run-image.sh/upload-image.sh scripts
at the repo root (see "Building" below).
Makefile Single root Makefile, builds both engine/ and
interpreter/, and packages a release (`make package`).
```
@@ -78,6 +89,29 @@ make package # -> dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz, a
Requires only a C99 compiler and `make` — no other dependencies.
**Building the watch app itself** (`watch/`, the actual Pebble app)
needs the separate Pebble SDK (`pebble` on `PATH`), so it's a separate
target, not part of `make`/`make test`/`make package` above:
```bash
make watchapp # -> dist/deck-in-a-dash-<version>.pbw, same version
# resolution as `make package`
```
**CI runs `make watchapp` too**`.gitea/workflows/build.yml` builds
and tests everything inside a container built from `build-image/`,
which bundles the Pebble SDK alongside the C toolchain so the runner
itself doesn't need either installed. To build/test that image
yourself, copy `registry.env.example` to `registry.env` and fill in
your registry credentials, then:
```bash
./build-image.sh # build and locally tag the image
./run-image.sh # run `make test package watchapp` inside it, against
# this checkout, for a CI-equivalent local build
./upload-image.sh # push the image so CI can pull it
```
## Running
**Daily use**, via `dist/run-engine.sh`: pulls today's date and the
Executable
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Builds the release build environment image (build-image/Dockerfile)
# locally, tagged with build-image/VERSION and :latest. Does not push -
# test it with ./run-image.sh first, then publish with ./upload-image.sh.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/registry.env" ] \
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
# shellcheck disable=SC1091
source "$ROOT/registry.env"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
VERSION="$(<"$ROOT/build-image/VERSION")"
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
echo "== Building $REGISTRY_IMAGE:$VERSION =="
docker build \
-t "$REGISTRY_IMAGE:$VERSION" \
-t "$REGISTRY_IMAGE:latest" \
"$ROOT/build-image"
echo "== Done =="
echo "Image: $REGISTRY_IMAGE:$VERSION (and :latest)"
echo "Test it with ./run-image.sh, then publish with ./upload-image.sh"
+49
View File
@@ -0,0 +1,49 @@
# Build environment for deck_in_a_dash release artifacts: a plain C
# toolchain for the desktop deck-engine/interpreter-cli binaries (`make
# test`, `make package`) plus the Pebble SDK for the watch app (`make
# watchapp`), pinned to the versions validated on the maintainer's dev
# machine. Rebuild with ../build-image.sh whenever a version below
# changes.
#
# Does NOT contain any secrets - registry/SFTP credentials are injected
# at job runtime from Gitea Actions secrets, never baked into this image.
FROM ubuntu:22.04
ARG PEBBLE_TOOL_VERSION=5.0.39
ARG PEBBLE_SDK_CORE_VERSION=4.17
ARG NODE_VERSION=24.16.0
ENV DEBIAN_FRONTEND=noninteractive \
PATH=/root/.local/bin:${PATH}
# build-essential: gcc/make for the desktop deck-engine/interpreter-cli
# binaries (`make test`, `make package`).
# python3/python3-venv/python3-pip: `pebble sdk install` below creates a
# venv per SDK version; Pillow (installed below) is needed at `pebble
# build` time by watch/scripts/gen_card_images.py and gen_app_icon.py.
# git/curl/ca-certificates/unzip/tar/xz-utils: checkout, tool downloads,
# and `make package`/`make watchapp`'s `git describe --tags` versioning.
# openssh-client: the build workflow's `sftp` publish to dl.ladkau.de.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential python3 python3-venv python3-pip \
git curl ca-certificates unzip tar xz-utils openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir Pillow
# Node.js: `pebble sdk install` below runs `npm install` for the SDK
# core's bundled webpack tooling - it does not bring its own node/npm.
RUN curl -sSL -o /tmp/node.tar.xz \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
&& rm /tmp/node.tar.xz
# Pebble SDK: pebble-tool (via uv, pipx-style isolated install) + the
# sdk-core toolchain/webpack deps for PEBBLE_SDK_CORE_VERSION, both
# fully resolved here so no `docker run` of this image needs network
# access to install anything before `pebble build` works.
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& uv tool install "pebble-tool==${PEBBLE_TOOL_VERSION}" \
&& pebble sdk install "${PEBBLE_SDK_CORE_VERSION}"
WORKDIR /workspace
+1
View File
@@ -0,0 +1 @@
1
+52 -15
View File
@@ -17,6 +17,7 @@ same `--lang`) for its own report, independently of `deck-engine`.
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
[--date YYYY-MM-DDTHH:MM] [--format text|html|json]
[--gender male|female|unspecified]
```
| Flag | Required | Format | Meaning |
@@ -29,6 +30,7 @@ deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
| `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. |
| `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. |
| `--format` | no | `text` \| `html` \| `json` | Output format, defaults to `text`. `json` always uses language-independent identifiers, ignoring `--lang`. |
| `--gender` | no | `male` \| `female` \| `unspecified` | Who the reading is for. Only affects the Celtic Cross position text's pronouns (e.g. "His House" vs. "Her House" vs. "Their House") - never the chart/draw itself. Defaults to `unspecified` (pronoun-neutral phrasing). |
| `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `<code>.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. |
| `--i18n-dir` | no | path | Directory to look for `<lang>.lang` in. Defaults to an `i18n/` directory next to the binary itself (resolved from `argv[0]`, so `dist/deck-engine` finds `dist/i18n/` regardless of the caller's working directory). |
@@ -52,6 +54,7 @@ use:
birth_utc_offset=2
birth_lat=52.5200
birth_lon=13.4050
gender=unspecified
lang=en
```
@@ -59,8 +62,8 @@ use:
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh`
checks every birth field for an empty value or a leftover `YOUR_`
placeholder and refuses to run with a clear error until the file is
filled in; `lang` has no such check since it always has a usable
default (`en`). Since `dist/` itself can be wiped (`make clean`, or
filled in; `gender` and `lang` have no such check since they always
have a usable default (`unspecified`/`en`). Since `dist/` itself can be wiped (`make clean`, or
deleting the directory), the build also keeps a durable backup at
`engine/scripts/user.properties.local` (gitignored) once the file
looks filled in, and restores from it if `dist/user.properties` is
@@ -81,6 +84,14 @@ catalog with the built-in English text as the fallback for any key the
catalog doesn't have. See `CLAUDE.md`'s "Translations" section for the
key-naming convention and how to add a new language.
Celtic Cross position text additionally varies with `--gender`: the
plain key (e.g. `position.present.desc`) is the pronoun-neutral wording
used for `unspecified`, and `.male`/`.female`-suffixed keys (e.g.
`position.present.desc.male`) hold the gendered wording where it
differs. A position whose wording doesn't vary by gender (e.g. "The
Outcome") simply has no `.male`/`.female` keys - the neutral key's
translation is used for every `--gender` value in that case.
`engine/i18n/en.lang` and `de.lang` are the shipped translation files;
`make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every
build, like `dist/img/`). Adding a language is just dropping another
@@ -146,7 +157,7 @@ Transiting Sun Opposition natal Moon (orb 3.7°)
===== Celtic Cross =====
The Present The High Priestess (Reversed)
This covers him: the general influence affecting the matter.
This covers them: the general influence affecting the matter.
Passion, moral or physical ardour, conceit, surface knowledge.
...
```
@@ -171,8 +182,8 @@ cards rendered as an image:
### `--format json`
A single JSON object mirroring `DailyReading` exactly — `natal`,
`transits`, `spread` — using the **slug** accessors
A single JSON object mirroring `DailyReading` exactly — `date`, `gender`,
`natal`, `transits`, `spread` — using the **slug** accessors
(`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`,
`astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`)
rather than the display-name ones, so the output is identical regardless
@@ -182,6 +193,8 @@ read.
```json
{
"date": "2026-07-16",
"gender": "unspecified",
"natal": {
"bodies": [
{"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3},
@@ -210,6 +223,17 @@ read.
}
```
- `date` is the UTC calendar date of `--date` (or the current system time
if `--date` was omitted) — the day this reading is *for*, not
necessarily the day it was generated on. `reading_generate()` stores
the full moment in `DailyReading.utc_moment`; only the date part is
serialized here since that's the only thing the rest of this format
(and the interpreter's own reports, which read this field back — see
"The interpreter" below) ever display.
- `gender` is `--gender`'s value (`male`/`female`/`unspecified`,
`tarot_gender_slug()`) — who the reading is for. Only affects the
Celtic Cross position text's pronouns, nothing else in this document.
The interpreter reads this field back the same way it reads `date`.
- `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum
order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in
`CelticCrossPosition` enum order (Present, Challenge, Crown,
@@ -279,11 +303,16 @@ dist/run-interpreter.sh html de # override format/language for this run
### `--format text`
A plain-text report, printed in a fixed order —
1. the day's overall significance level (`Quiet`/`Notable`/
1. the day this reading is for (`Reading for: 2026-07-16`), from the
input JSON's top-level `date` field (see `--format json` above) —
not necessarily the day the report is actually being read on, if
`reading.json` was generated earlier and piped into
`interpreter-cli` later;
2. the day's overall significance level (`Quiet`/`Notable`/
`Significant`/`Major`, plus its rank out of the 4 defined levels,
e.g. `Notable (2/4)`), with a "worth a deeper Celtic Cross look"
line on `Major` days only;
2. up to 5 of today's aspects ranked by score, each naming the sign
3. up to 5 of today's aspects ranked by score, each naming the sign
and natal house the transiting planet currently occupies and the
sign and house of the natal planet it's aspecting, followed by a
short narrative sentence about what that transiting planet
@@ -291,10 +320,10 @@ A plain-text report, printed in a fixed order —
governs* (see `CLAUDE.md`'s `narrative.c` bullet) — omitted only if
the source material has nothing to say for that planet/aspect
combination;
3. the full 10-position Celtic Cross spread, in Waite's own drawing
4. the full 10-position Celtic Cross spread, in Waite's own drawing
order, each with its card (and orientation), the position's own
description, and that card's actual meaning;
4. finally, a guidance paragraph tying the day's top transit to the
5. finally, a guidance paragraph tying the day's top transit to the
spread's Attitude/Outcome cards, with its opening sentence tailored
to `day_level` (stronger wording on `Major`, softer on
`Quiet`/`Notable`, falling back to a transit-free reading of the
@@ -305,6 +334,8 @@ A plain-text report, printed in a fixed order —
seen the full spread it references.
```
Reading for: 2026-07-16
Day significance: Major (4/4)
(a major transit today - worth a deeper Celtic Cross look)
@@ -318,14 +349,14 @@ Top 5 significant transits:
Celtic Cross:
1. The Present: The Emperor (Reversed)
This covers him: the general influence affecting the matter.
This covers them: the general influence affecting the matter.
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
2. The Challenge: The Devil
This crosses him: the nature of the obstacle in the matter.
This crosses them: the nature of the obstacle in the matter.
Ravage, violence, vehemence, extraordinary efforts, force, fatality.
...
7. Himself: The Magician
His position or attitude in the circumstances.
7. Themself: The Magician
Their position or attitude in the circumstances.
Skill, diplomacy, address, subtlety; self-confidence, will.
...
10. The Outcome: Strength
@@ -354,6 +385,8 @@ The same report with `--lang de` (same input as the Major example
above):
```
Lesung für: 2026-07-16
Bedeutung des Tages: Einschneidend (4/4)
(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
@@ -364,7 +397,7 @@ Top 5 wichtige Transits:
Keltisches Kreuz:
1. Die Gegenwart: Der Herrscher (Umgekehrt)
Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft.
Wohlwollen, Mitgefühl, Ansehen; auch Verwirrung der Feinde, Behinderung, Unreife.
...
@@ -390,7 +423,7 @@ HTML output:
<div class="position">The Present</div>
<img class="reversed" src="img/RWS_Tarot_04_Emperor.jpeg" alt="The Emperor">
<div class="name">The Emperor (Reversed)</div>
<div class="desc">This covers him: the general influence affecting the matter.</div>
<div class="desc">This covers them: the general influence affecting the matter.</div>
<div class="meaning">Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.</div>
</div>
```
@@ -415,6 +448,7 @@ has the slugs to work with directly.
```json
{
"date": "2026-07-16",
"day_significance": {
"level": "major",
"rank": 4,
@@ -448,6 +482,9 @@ has the slugs to work with directly.
}
```
- `date` is copied straight through from the input JSON's own top-level
`date` field (see `--format json` above) - always language-independent
regardless of `--lang`, same as every other slug in this document.
- `significant_transits` has `interp.top_item_count` entries (0-5, same
ranking as `--format text`); `celtic_cross` always has exactly 10, in
`CelticCrossPosition` enum order (same order as `deck-engine`'s own
+47 -12
View File
@@ -22,6 +22,12 @@ ui.transiting=Transit
ui.natal=natal
ui.orb=Orbis
# --- Nur die Watch-App (watch/src/c/main.c) - wird vor der ersten
# Einrichtung ueber die Konfigurationsseite gezeigt, wenn noch keine
# Geburtsdaten fuer eine Deutung vorliegen ---
ui.setup_required_title=Einrichtung erforderlich
ui.setup_required_body=Öffne die Einstellungen dieser App in der Pebble-App, um deine Geburtsdaten einzugeben, und starte Deck in a Dash danach erneut.
# --- Planeten (astro.c) ---
body.sun=Sonne
body.moon=Mond
@@ -65,25 +71,53 @@ aspect.square=Quadrat
aspect.trine=Trigon
aspect.opposition=Opposition
# --- Positionen des Keltischen Kreuzes (tarot_data.c) ---
# --- Positionen des Keltischen Kreuzes (tarot_data.c). Waite spricht die
# fragende Person durchgehend mit "ihn/ihm/sein" an; der unsuffigierte
# Schlüssel ist eine geschlechtsneutrale Umschreibung (über "die fragende
# Person"/"die eigene", ohne Pronomen), die bei nicht angegebenem
# Geschlecht verwendet wird, ".male" ist Waites eigener Wortlaut
# unverändert, ".female" eine für dieses Projekt geschriebene Umschreibung -
# siehe tarot_data.c's Kommentar zu Gender. ---
position.present.name=Die Gegenwart
position.present.desc=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
position.present.desc=Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft.
position.present.desc.male=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
position.present.desc.female=Dies bedeckt sie: der allgemeine Einfluss, der die Angelegenheit betrifft.
position.challenge.name=Die Herausforderung
position.challenge.desc=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit.
position.challenge.desc=Dies kreuzt die fragende Person: die Art des Hindernisses in der Angelegenheit.
position.challenge.desc.male=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit.
position.challenge.desc.female=Dies kreuzt sie: die Art des Hindernisses in der Angelegenheit.
position.crown.name=Die Krone
position.crown.desc=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann.
position.crown.desc=Dies krönt die fragende Person: das Ziel oder Ideal, das Beste, was erreicht werden kann.
position.crown.desc.male=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann.
position.crown.desc.female=Dies krönt sie: das Ziel oder Ideal, das Beste, was erreicht werden kann.
position.foundation.name=Das Fundament
position.foundation.desc=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit.
position.foundation.desc=Dies liegt unter der fragenden Person: die Grundlage der Angelegenheit, bereits Wirklichkeit.
position.foundation.desc.male=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit.
position.foundation.desc.female=Dies liegt unter ihr: die Grundlage der Angelegenheit, bereits Wirklichkeit.
position.recent_past.name=Die jüngste Vergangenheit
position.recent_past.desc=Dies liegt hinter ihm: der Einfluss, der gerade vergeht.
position.recent_past.desc=Dies liegt hinter der fragenden Person: der Einfluss, der gerade vergeht.
position.recent_past.desc.male=Dies liegt hinter ihm: der Einfluss, der gerade vergeht.
position.recent_past.desc.female=Dies liegt hinter ihr: der Einfluss, der gerade vergeht.
position.near_future.name=Die nahe Zukunft
position.near_future.desc=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
position.attitude.name=Er selbst
position.attitude.desc=Seine Haltung oder Einstellung in den gegebenen Umständen.
position.environment.name=Sein Haus
position.environment.desc=Sein Umfeld und die darin wirkenden Tendenzen.
position.near_future.desc=Dies liegt vor der fragenden Person: der Einfluss, der jetzt wirksam wird.
position.near_future.desc.male=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
position.near_future.desc.female=Dies liegt vor ihr: der Einfluss, der jetzt wirksam wird.
position.attitude.name=Die fragende Person selbst
position.attitude.name.male=Er selbst
position.attitude.name.female=Sie selbst
position.attitude.desc=Die eigene Haltung oder Einstellung in den gegebenen Umständen.
position.attitude.desc.male=Seine Haltung oder Einstellung in den gegebenen Umständen.
position.attitude.desc.female=Ihre Haltung oder Einstellung in den gegebenen Umständen.
position.environment.name=Das eigene Haus
position.environment.name.male=Sein Haus
position.environment.name.female=Ihr Haus
position.environment.desc=Das eigene Umfeld und die darin wirkenden Tendenzen.
position.environment.desc.male=Sein Umfeld und die darin wirkenden Tendenzen.
position.environment.desc.female=Ihr Umfeld und die darin wirkenden Tendenzen.
position.hopes_and_fears.name=Hoffnungen und Ängste
position.hopes_and_fears.desc=Seine Hoffnungen oder Ängste in der Angelegenheit.
position.hopes_and_fears.desc=Die eigenen Hoffnungen oder Ängste in der Angelegenheit.
position.hopes_and_fears.desc.male=Seine Hoffnungen oder Ängste in der Angelegenheit.
position.hopes_and_fears.desc.female=Ihre Hoffnungen oder Ängste in der Angelegenheit.
position.outcome.name=Das Ergebnis
position.outcome.desc=Was kommen wird: das endgültige Ergebnis der Angelegenheit.
@@ -156,6 +190,7 @@ card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Fluc
card.world.reversed=Trägheit, Erstarrung, Stillstand, Beständigkeit.
# --- UI-Bezeichnungen des Interpreters (interpreter/src/main.c) ---
interp.reading_date=Lesung für:
interp.day_significance=Bedeutung des Tages:
interp.day_level.quiet=Ruhig
interp.day_level.notable=Bemerkenswert
+46 -12
View File
@@ -29,6 +29,12 @@ ui.transiting=Transiting
ui.natal=natal
ui.orb=orb
# --- Watch app only (watch/src/c/main.c) - shown before the first
# config-page submission, when there's no birth data yet to compute a
# reading from ---
ui.setup_required_title=Setup Required
ui.setup_required_body=Open this app's settings from the Pebble mobile app to add your birth details, then reopen Deck in a Dash.
# --- Planets/luminaries (astro.c) ---
body.sun=Sun
body.moon=Moon
@@ -73,25 +79,52 @@ aspect.trine=Trine
aspect.opposition=Opposition
# --- Celtic Cross position names/descriptions (tarot_data.c), Waite's
# own wording from "An Ancient Celtic Method of Divination" ---
# own wording from "An Ancient Celtic Method of Divination". Waite
# addresses the querent as "him" throughout; the unsuffixed key is a
# pronoun-neutral paraphrase (singular "they") used when no gender is
# configured, the ".male" key is Waite's own wording verbatim, and the
# ".female" key is an original paraphrase - see tarot_data.c's own
# comment on Gender. ---
position.present.name=The Present
position.present.desc=This covers him: the general influence affecting the matter.
position.present.desc=This covers them: the general influence affecting the matter.
position.present.desc.male=This covers him: the general influence affecting the matter.
position.present.desc.female=This covers her: the general influence affecting the matter.
position.challenge.name=The Challenge
position.challenge.desc=This crosses him: the nature of the obstacle in the matter.
position.challenge.desc=This crosses them: the nature of the obstacle in the matter.
position.challenge.desc.male=This crosses him: the nature of the obstacle in the matter.
position.challenge.desc.female=This crosses her: the nature of the obstacle in the matter.
position.crown.name=The Crown
position.crown.desc=This crowns him: the aim or ideal, the best that can be achieved.
position.crown.desc=This crowns them: the aim or ideal, the best that can be achieved.
position.crown.desc.male=This crowns him: the aim or ideal, the best that can be achieved.
position.crown.desc.female=This crowns her: the aim or ideal, the best that can be achieved.
position.foundation.name=The Foundation
position.foundation.desc=This is beneath him: the basis of the matter, already actual.
position.foundation.desc=This is beneath them: the basis of the matter, already actual.
position.foundation.desc.male=This is beneath him: the basis of the matter, already actual.
position.foundation.desc.female=This is beneath her: the basis of the matter, already actual.
position.recent_past.name=The Recent Past
position.recent_past.desc=This is behind him: the influence that is just passing away.
position.recent_past.desc=This is behind them: the influence that is just passing away.
position.recent_past.desc.male=This is behind him: the influence that is just passing away.
position.recent_past.desc.female=This is behind her: the influence that is just passing away.
position.near_future.name=The Near Future
position.near_future.desc=This is before him: the influence now coming into action.
position.attitude.name=Himself
position.attitude.desc=His position or attitude in the circumstances.
position.environment.name=His House
position.environment.desc=His environment and the tendencies at work therein.
position.near_future.desc=This is before them: the influence now coming into action.
position.near_future.desc.male=This is before him: the influence now coming into action.
position.near_future.desc.female=This is before her: the influence now coming into action.
position.attitude.name=Themself
position.attitude.name.male=Himself
position.attitude.name.female=Herself
position.attitude.desc=Their position or attitude in the circumstances.
position.attitude.desc.male=His position or attitude in the circumstances.
position.attitude.desc.female=Her position or attitude in the circumstances.
position.environment.name=Their House
position.environment.name.male=His House
position.environment.name.female=Her House
position.environment.desc=Their environment and the tendencies at work therein.
position.environment.desc.male=His environment and the tendencies at work therein.
position.environment.desc.female=Her environment and the tendencies at work therein.
position.hopes_and_fears.name=Hopes and Fears
position.hopes_and_fears.desc=His hopes or fears in the matter.
position.hopes_and_fears.desc=Their hopes or fears in the matter.
position.hopes_and_fears.desc.male=His hopes or fears in the matter.
position.hopes_and_fears.desc.female=Her hopes or fears in the matter.
position.outcome.name=The Outcome
position.outcome.desc=What will come: the final result of the matter.
@@ -165,6 +198,7 @@ card.world.upright=Assured success, recompense, voyage, route, emigration, fligh
card.world.reversed=Inertia, fixity, stagnation, permanence.
# --- Interpreter UI labels (interpreter/src/main.c) ---
interp.reading_date=Reading for:
interp.day_significance=Day significance:
interp.day_level.quiet=Quiet
interp.day_level.notable=Notable
+3
View File
@@ -29,6 +29,7 @@ birth_time=""
birth_utc_offset=""
birth_lat=""
birth_lon=""
gender="unspecified"
lang="en"
while IFS='=' read -r key value || [ -n "$key" ]; do
@@ -43,6 +44,7 @@ while IFS='=' read -r key value || [ -n "$key" ]; do
birth_utc_offset) birth_utc_offset="$value" ;;
birth_lat) birth_lat="$value" ;;
birth_lon) birth_lon="$value" ;;
gender) [ -n "$value" ] && gender="$value" ;;
lang) [ -n "$value" ] && lang="$value" ;;
esac
done <"$PROPERTIES_FILE"
@@ -77,5 +79,6 @@ exec "$BINARY" \
--birth-lon "$birth_lon" \
--date "$transit_date" \
--format "$format" \
--gender "$gender" \
--lang "$lang" \
--i18n-dir "$SCRIPT_DIR/i18n"
+3
View File
@@ -41,6 +41,7 @@ birth_time=""
birth_utc_offset=""
birth_lat=""
birth_lon=""
gender="unspecified"
lang="en"
while IFS='=' read -r key value || [ -n "$key" ]; do
@@ -55,6 +56,7 @@ while IFS='=' read -r key value || [ -n "$key" ]; do
birth_utc_offset) birth_utc_offset="$value" ;;
birth_lat) birth_lat="$value" ;;
birth_lon) birth_lon="$value" ;;
gender) [ -n "$value" ] && gender="$value" ;;
lang) [ -n "$value" ] && lang="$value" ;;
esac
done <"$PROPERTIES_FILE"
@@ -92,4 +94,5 @@ lang="${2:-$lang}"
--birth-lon "$birth_lon" \
--date "$transit_date" \
--format json \
--gender "$gender" \
| exec "$INTERPRETER_BINARY" --format "$format" --lang "$lang" --i18n-dir "$SCRIPT_DIR/i18n"
+7
View File
@@ -11,6 +11,12 @@
# (e.g. 2 for Central European Summer Time)
# birth_lat Birth location latitude, decimal degrees, north positive
# birth_lon Birth location longitude, decimal degrees, east positive
# gender Who the reading is for - male, female, or unspecified.
# Only affects the Celtic Cross position text's
# pronouns (e.g. "His House" vs. "Her House" vs.
# "Their House"), never the chart/draw itself.
# Optional, defaults to "unspecified"; unrecognized
# values fall back to "unspecified" too.
# lang Reading language code - matches a file named
# <code>.lang in dist/i18n/ (see engine/i18n/ for the
# source files, currently "en" and "de"). Optional,
@@ -22,4 +28,5 @@ birth_time=YOUR_BIRTH_TIME_HERE
birth_utc_offset=YOUR_UTC_OFFSET_HERE
birth_lat=YOUR_LATITUDE_HERE
birth_lon=YOUR_LONGITUDE_HERE
gender=unspecified
lang=en
+104 -25
View File
@@ -1,14 +1,22 @@
#define _POSIX_C_SOURCE 200809L /* for gmtime_r */
#include "astro.h"
#include "i18n.h"
/* The watch build (PBL_SDK_3) uses a compact low-precision ephemeris
* instead of the vendored Astronomy Engine, which at ~127KB of compiled
* code doesn't fit a whole Pebble app's ~64KB budget - see
* lowprec_ephemeris.h's own doc comment. AstroTime abstracts over the
* two representations of "a moment in time" this file can be built
* against: a Julian Date (double) for the watch, or the vendored
* library's own astro_time_t on desktop. Every function below that takes
* or returns a time branches internally on PBL_SDK_3, but keeps the same
* name/signature/call sites either way, so the two implementations stay
* easy to compare line-by-line. */
#ifdef PBL_SDK_3
#include "lowprec_ephemeris.h"
typedef double AstroTime;
#else
#include "../third_party/astronomy/astronomy.h"
#include <math.h>
#include <stdbool.h>
#include <string.h>
/* DEG2RAD / RAD2DEG come from astronomy.h. */
typedef astro_time_t AstroTime;
static const astro_body_t k_astro_body[NUM_BODIES] = {
[PLANET_SUN] = BODY_SUN,
@@ -22,6 +30,20 @@ static const astro_body_t k_astro_body[NUM_BODIES] = {
[PLANET_NEPTUNE] = BODY_NEPTUNE,
[PLANET_PLUTO] = BODY_PLUTO,
};
#endif
#include <math.h>
#include <stdbool.h>
#include <string.h>
/* Not M_PI - see lowprec_ephemeris.c's own comment on why. Only needed
* (and only defined) on the watch path; astronomy.h supplies these on
* desktop, as before. */
#ifdef PBL_SDK_3
#define DECK_PI 3.14159265358979323846
#define DEG2RAD (DECK_PI / 180.0)
#define RAD2DEG (180.0 / DECK_PI)
#endif
static double normalize_degrees(double deg) {
double d = fmod(deg, 360.0);
@@ -36,45 +58,91 @@ static void longitude_to_position(double longitude, PlanetPosition *out) {
/* Mean obliquity of the ecliptic (IAU low-precision polynomial), matching
* what Astronomy Engine computes internally in its (non-exported)
* mean_obliq(). T is Julian centuries since J2000.0. */
* mean_obliq(). T is Julian centuries since J2000.0. A slow, purely
* time-based polynomial, not subject to orbital approximation error -
* shared unchanged by both ephemeris implementations. */
static double mean_obliquity_deg(double t_centuries) {
double t = t_centuries;
return 23.4392911 - 0.0130042 * t - 0.00000016 * t * t + 0.000000504 * t * t * t;
}
static double julian_centuries_since_j2000(const AstroTime *time) {
#ifdef PBL_SDK_3
return (*time - 2451545.0) / 36525.0;
#else
return time->tt / 36525.0;
#endif
}
static double sidereal_time_hours(AstroTime *time) {
#ifdef PBL_SDK_3
return lowprec_gmst_hours(*time);
#else
return Astronomy_SiderealTime(time);
#endif
}
/* Ascendant (rising ecliptic degree), via the standard RAMC/obliquity/
* latitude identity (Duffett-Smith & Zwart, "Practical Astronomy with
* your Calculator or Spreadsheet"). Astronomy Engine has no built-in
* Ascendant function. */
static double compute_ascendant(astro_time_t *time, double latitude_deg,
* your Calculator or Spreadsheet"). Neither ephemeris implementation has
* a built-in Ascendant function. */
static double compute_ascendant(AstroTime *time, double latitude_deg,
double longitude_deg) {
double gast_hours = Astronomy_SiderealTime(time);
double gast_hours = sidereal_time_hours(time);
double ramc_deg = normalize_degrees(gast_hours * 15.0 + longitude_deg);
double eps_deg = mean_obliquity_deg(time->tt / 36525.0);
double eps_deg = mean_obliquity_deg(julian_centuries_since_j2000(time));
double ramc = ramc_deg * DEG2RAD;
double eps = eps_deg * DEG2RAD;
double lat = latitude_deg * DEG2RAD;
/* tan(lat) as sin(lat)/cos(lat) - no <math.h> tan() needed. */
#ifdef PBL_SDK_3
double y = -lowprec_cos(ramc);
double x = lowprec_sin(ramc) * lowprec_cos(eps) +
(lowprec_sin(lat) / lowprec_cos(lat)) * lowprec_sin(eps);
return normalize_degrees(lowprec_atan2(y, x) * RAD2DEG);
#else
double y = -cos(ramc);
double x = sin(ramc) * cos(eps) + tan(lat) * sin(eps);
double x = sin(ramc) * cos(eps) + (sin(lat) / cos(lat)) * sin(eps);
return normalize_degrees(atan2(y, x) * RAD2DEG);
#endif
}
static astro_time_t time_from_birth(const BirthData *birth) {
static AstroTime time_from_birth(const BirthData *birth) {
#ifdef PBL_SDK_3
double jd = lowprec_julian_date(birth->year, birth->month, birth->day,
birth->hour, birth->minute, 0.0);
return jd - birth->utc_offset_hours / 24.0;
#else
astro_time_t local = Astronomy_MakeTime(birth->year, birth->month, birth->day,
birth->hour, birth->minute, 0.0);
return Astronomy_AddDays(local, -birth->utc_offset_hours / 24.0);
#endif
}
static astro_time_t time_from_unix(time_t utc_moment) {
struct tm tm_utc;
gmtime_r(&utc_moment, &tm_utc);
return Astronomy_MakeTime(tm_utc.tm_year + 1900, tm_utc.tm_mon + 1, tm_utc.tm_mday,
tm_utc.tm_hour, tm_utc.tm_min, (double)tm_utc.tm_sec);
/* Plain gmtime() (not the POSIX-only gmtime_r()) - deliberately: this
* project is single-threaded everywhere it runs, including on the watch,
* where only the non-reentrant classic C library functions are provided
* (see astro.h's own include-guard comment). Used immediately, so its
* static internal buffer is never a hazard here. */
static AstroTime time_from_unix(time_t utc_moment) {
struct tm *tm_utc = gmtime(&utc_moment);
#ifdef PBL_SDK_3
return lowprec_julian_date(tm_utc->tm_year + 1900, tm_utc->tm_mon + 1, tm_utc->tm_mday,
tm_utc->tm_hour, tm_utc->tm_min, (double)tm_utc->tm_sec);
#else
return Astronomy_MakeTime(tm_utc->tm_year + 1900, tm_utc->tm_mon + 1, tm_utc->tm_mday,
tm_utc->tm_hour, tm_utc->tm_min, (double)tm_utc->tm_sec);
#endif
}
static void compute_body_positions(astro_time_t time, PlanetPosition out[NUM_BODIES]) {
static void compute_body_positions(AstroTime time, PlanetPosition out[NUM_BODIES]) {
#ifdef PBL_SDK_3
for (int b = 0; b < NUM_BODIES; b++) {
longitude_to_position(lowprec_geocentric_longitude((Body)b, time), &out[b]);
}
#else
/* Astronomy_EclipticLongitude() computes *heliocentric* longitude (and
* outright rejects BODY_SUN) - wrong for astrology, which needs the
* apparent geocentric position. Astronomy_GeoVector() + Astronomy_Ecliptic()
@@ -84,6 +152,7 @@ static void compute_body_positions(astro_time_t time, PlanetPosition out[NUM_BOD
astro_ecliptic_t eclip = Astronomy_Ecliptic(geo);
longitude_to_position(eclip.elon, &out[b]);
}
#endif
}
/* Whole-sign house number (1-12) for `sign`, counting from `ascendant_sign`
@@ -101,7 +170,7 @@ static void assign_houses(PlanetPosition bodies[NUM_BODIES], ZodiacSign ascendan
}
void astro_compute_natal_chart(const BirthData *birth, NatalChart *out) {
astro_time_t time = time_from_birth(birth);
AstroTime time = time_from_birth(birth);
compute_body_positions(time, out->bodies);
out->ascendant_longitude = compute_ascendant(&time, birth->latitude, birth->longitude);
@@ -142,13 +211,23 @@ static double orb_for_pair(Body transiting, Body natal) {
void astro_compute_daily_transits(time_t utc_moment, const NatalChart *natal,
DailyTransits *out) {
astro_time_t time = time_from_unix(utc_moment);
AstroTime time = time_from_unix(utc_moment);
compute_body_positions(time, out->bodies);
assign_houses(out->bodies, natal->houses[0]);
#ifdef PBL_SDK_3
/* Sun-to-Moon angle directly from the positions just computed above -
* same convention as Astronomy_MoonPhase() (0 = new, 90 = first
* quarter, 180 = full, 270 = last quarter), so moon_phase_from_angle()
* below needs no platform-specific branch of its own. */
double phase_angle = normalize_degrees(out->bodies[PLANET_MOON].ecliptic_longitude -
out->bodies[PLANET_SUN].ecliptic_longitude);
#else
astro_angle_result_t phase = Astronomy_MoonPhase(time);
out->moon_phase = moon_phase_from_angle(phase.angle);
double phase_angle = phase.angle;
#endif
out->moon_phase = moon_phase_from_angle(phase_angle);
out->aspect_count = 0;
for (int t = 0; t < NUM_BODIES; t++) {
+10
View File
@@ -1,7 +1,17 @@
#ifndef DECK_ASTRO_H
#define DECK_ASTRO_H
/* Pebble's SDK pre-defines <time.h>'s own include guard (so a real
* #include <time.h> is a silent no-op) and instead declares struct
* tm/time_t/gmtime()/etc. itself, inside pebble.h - see PBL_SDK_3,
* predefined by the Pebble build for every target platform. Desktop
* builds (deck-engine, the interpreter, and this file's own tests) use
* plain <time.h> as normal. */
#ifdef PBL_SDK_3
#include <pebble.h>
#else
#include <time.h>
#endif
#define NUM_BODIES 10
#define MAX_ASPECTS 100
+32 -5
View File
@@ -1,12 +1,33 @@
#include "i18n.h"
#include <stdio.h>
#include <string.h>
/* Fixed-size, no heap allocation - deliberately (see i18n.h): keeps this
* usable in a small embedded environment later, not just on desktop.
* ~140 keys are shipped today (see engine/i18n/en.lang); this leaves
* plenty of headroom for more languages/keys without growing the format. */
/* i18n_load_table()'s catalog: just three pointers, always compiled -
* negligible RAM regardless of platform. Deliberately zero-copy: the
* watch's compiled-in translation tables already live in flash as
* `static const char *const` arrays, so i18n_get() reads them in place
* rather than copying ~140 keys/values into RAM the watch doesn't have
* (see i18n_load()'s g_entries[] below, sized for desktop use only). A
* later i18n_load_table() call (e.g. switching language) simply
* re-points these, no free() needed since nothing was ever copied. */
static const char *const *g_table_keys;
static const char *const *g_table_values;
static int g_table_count = 0;
void i18n_load_table(const char *const *keys, const char *const *values, int count) {
g_table_keys = keys;
g_table_values = values;
g_table_count = count;
}
#ifndef PBL_SDK_3
#include <stdio.h>
/* Fixed-size, no heap allocation. ~140 keys are shipped today (see
* engine/i18n/en.lang); this leaves plenty of headroom for more
* languages/keys without growing the format. Desktop-only: at ~140KB
* this is far too large for the watch's own RAM budget, which is why
* the watch instead uses i18n_load_table()'s zero-copy path above. */
#define I18N_MAX_ENTRIES 320
#define I18N_MAX_KEY 48
#define I18N_MAX_VALUE 400
@@ -67,10 +88,16 @@ bool i18n_load(const char *path) {
fclose(f);
return true;
}
#endif /* PBL_SDK_3 */
const char *i18n_get(const char *key, const char *fallback) {
for (int i = 0; i < g_table_count; i++) {
if (strcmp(g_table_keys[i], key) == 0) return g_table_values[i];
}
#ifndef PBL_SDK_3
for (int i = 0; i < g_entry_count; i++) {
if (strcmp(g_entries[i].key, key) == 0) return g_entries[i].value;
}
#endif
return fallback;
}
+20 -4
View File
@@ -9,11 +9,27 @@
* file can't be opened (the catalog is left as it was in that case).
*
* This uses stdio, so - like main.c and reading_print_text/_html - it's
* a desktop-only entry point, not something the Pebble watchapp will
* call as-is. i18n_get() below is a pure table lookup, so it's fine to
* port; the watchapp will just need its own (non-file-based) way to
* populate the catalog, e.g. from a compiled-in resource. */
* a desktop-only entry point: Pebble's SDK doesn't provide fopen/fgets
* at all (a compile-time error, not just a runtime failure), so this
* function isn't even compiled when building for the watch (guarded
* #ifndef PBL_SDK_3 in i18n.c) - see i18n_load_table() below, its
* watch-side equivalent. */
#ifndef PBL_SDK_3
bool i18n_load(const char *path);
#endif
/* The watch's non-file-based equivalent of i18n_load(): points i18n_get()
* at two parallel arrays (keys[i] -> values[i], `count` entries) instead
* of parsing a file - for a compiled-in translation table generated from
* engine/i18n's .lang files at watch-app build time (see the watch
* app's own build tooling). Zero-copy - just stores the three pointers -
* so `keys`/`values` must outlive the call (a `static const char *const`
* array is the expected caller, living in flash/ROM, not a local/heap
* buffer). This is what keeps the watch from needing i18n_load()'s
* ~140KB g_entries[] catalog, which its RAM budget can't afford.
* Available on every platform, since it has no platform-specific
* dependencies, but only the watch actually calls it today. */
void i18n_load_table(const char *const *keys, const char *const *values, int count);
/* Returns the translation for `key` from the loaded catalog, or
* `fallback` if no catalog is loaded or `key` isn't in it. Every caller
+345
View File
@@ -0,0 +1,345 @@
#include "lowprec_ephemeris.h"
#include <math.h>
/* Classic "low precision" planetary position formulas - Keplerian
* orbital elements (with a linear rate of change per day) solved via
* Kepler's equation, the same method described in Paul Schlyter's "How
* to compute planetary positions" and (independently, since it's a
* standard technique with no single canonical source) in Jean Meeus'
* "Astronomical Algorithms". Elements are as of epoch J2000.0.
*
* Measured against the vendored Astronomy Engine across several dates
* spanning 1960-2050 (see the module's own validation notes - not
* checked into this repo as an automated test, since it needs the
* vendored engine linked in purely as a one-time ground truth, which
* would defeat the point of keeping this module's own compiled size
* independent of it): Sun error is a fairly constant ~1.4-1.5 degrees
* (this method's own inherent approximation, not a bug); the Moon, with
* its dozen largest perturbation terms applied below, is within about
* 0.1-0.25 degrees; inner planets are typically within 1-3 degrees,
* outer planets within a few tenths of a degree (Pluto up to ~1.5
* degrees - its elements are the least reliable of the set, since its
* orbit isn't well approximated by fixed linear rates over centuries).
* GMST (lowprec_gmst_hours(), for the Ascendant) matched to within
* 0.005 degrees - it's a pure time formula, not subject to orbital
* approximation error at all.
*
* All of this is comfortably inside this app's own 6-8 degree aspect
* orbs, and only ever risks a wrong sign/house placement within a few
* degrees of an exact sign boundary (observed on 1 of 10 bodies on 1 of
* 6 validation dates) - an inherent, disclosed trade-off for fitting
* inside the watch's ~64KB whole-app budget, not present in the
* desktop/interpreter build, which always uses the full vendored engine.
*
* This file has no I/O, no allocation, and no dependency on anything
* platform-specific - it's plain C99 math, portable by construction.
*
* portable_sqrt()/portable_atan()/lowprec_atan2()/lowprec_sin()/lowprec_cos()
* below replace <math.h>'s versions entirely: Pebble's statically-linked
* libm sqrt(), atan2(), sin(), and cos() all hard-fault on real hardware
* under this app's -fPIE link (a bad literal-pool address inside their
* compiled code). fmod() is unaffected and used freely. */
/* Not M_PI - it's a BSD/POSIX math.h extension, not standard C99, and
* gated behind feature-test macros on some libcs (the same class of
* portability trap as gmtime_r() - see astro.c's own comment). */
#define DECK_PI 3.14159265358979323846
#define DEG2RAD (DECK_PI / 180.0)
#define RAD2DEG (180.0 / DECK_PI)
static double normalize_deg(double deg) {
double d = fmod(deg, 360.0);
return d < 0.0 ? d + 360.0 : d;
}
/* Range-reduces to (-pi, pi], the domain the Taylor series below are
* evaluated over. */
static double reduce_to_pi(double rad) {
double r = fmod(rad, 2.0 * DECK_PI);
if (r < 0.0) r += 2.0 * DECK_PI;
if (r > DECK_PI) r -= 2.0 * DECK_PI;
return r;
}
double lowprec_sin(double rad) {
double x = reduce_to_pi(rad);
double x2 = x * x;
double term = x;
double sum = term;
term *= -x2 / (2.0 * 3.0); sum += term; /* -x^3/3! */
term *= -x2 / (4.0 * 5.0); sum += term; /* +x^5/5! */
term *= -x2 / (6.0 * 7.0); sum += term; /* -x^7/7! */
term *= -x2 / (8.0 * 9.0); sum += term; /* +x^9/9! */
term *= -x2 / (10.0 * 11.0); sum += term; /* -x^11/11! */
term *= -x2 / (12.0 * 13.0); sum += term; /* +x^13/13! */
term *= -x2 / (14.0 * 15.0); sum += term; /* -x^15/15! */
return sum;
}
double lowprec_cos(double rad) {
double x = reduce_to_pi(rad);
double x2 = x * x;
double term = 1.0;
double sum = term;
term *= -x2 / (1.0 * 2.0); sum += term; /* -x^2/2! */
term *= -x2 / (3.0 * 4.0); sum += term; /* +x^4/4! */
term *= -x2 / (5.0 * 6.0); sum += term; /* -x^6/6! */
term *= -x2 / (7.0 * 8.0); sum += term; /* +x^8/8! */
term *= -x2 / (9.0 * 10.0); sum += term; /* -x^10/10! */
term *= -x2 / (11.0 * 12.0); sum += term; /* +x^12/12! */
term *= -x2 / (13.0 * 14.0); sum += term; /* -x^14/14! */
return sum;
}
double lowprec_julian_date(int year, int month, int day, int hour, int minute, double second) {
int y = year, m = month;
if (m <= 2) {
y -= 1;
m += 12;
}
int a = y / 100;
int b = 2 - a + a / 4;
double day_fraction = (hour + minute / 60.0 + second / 3600.0) / 24.0;
return (double)(int)(365.25 * (y + 4716)) + (double)(int)(30.6001 * (m + 1)) +
day + day_fraction + b - 1524.5;
}
double lowprec_gmst_hours(double jd) {
/* Meeus 12.4, dropping the T^2/T^3 terms (fractions of a second even
* over centuries - far below this module's own precision floor). */
double gmst_deg = normalize_deg(280.46061837 + 360.98564736629 * (jd - 2451545.0));
return gmst_deg / 15.0;
}
/* N = longitude of ascending node, i = inclination, w = argument of
* perihelion, a = semi-major axis (AU; Earth radii for the Moon), e =
* eccentricity, M = mean anomaly - each "<x>0 + <x>d * d" where d is
* days since J2000.0. The Sun's "orbit" here is really Earth's own
* heliocentric orbit (N = i = 0, so the Sun's geocentric position falls
* straight out of the same flat 2-body solver used for everything
* else); Earth's own heliocentric position for geocentrizing the other
* planets is simply the Sun's position negated. */
typedef struct {
double N0, Nd;
double i0, id;
double w0, wd;
double a0, ad;
double e0, ed;
double M0, Md;
} OrbitalElements;
static const OrbitalElements k_elements[NUM_BODIES] = {
[PLANET_SUN] = {
0.0, 0.0, 0.0, 0.0,
282.9404, 4.70935e-5,
1.000000, 0.0,
0.016709, -1.151e-9,
356.0470, 0.9856002585,
},
[PLANET_MOON] = {
125.1228, -0.0529538083,
5.1454, 0.0,
318.0634, 0.1643573223,
60.2666, 0.0,
0.054900, 0.0,
134.9634, 13.0649929509,
},
[PLANET_MERCURY] = {
48.3313, 3.24587e-5,
7.0047, 5.00e-8,
29.1241, 1.01444e-5,
0.387098, 0.0,
0.205635, 5.59e-10,
168.6562, 4.0923344368,
},
[PLANET_VENUS] = {
76.6799, 2.46590e-5,
3.3946, 2.75e-8,
54.8910, 1.38374e-5,
0.723330, 0.0,
0.006773, -1.302e-9,
48.0052, 1.6021302244,
},
[PLANET_MARS] = {
49.5574, 2.11081e-5,
1.8497, -1.78e-8,
286.5016, 2.92961e-5,
1.523688, 0.0,
0.093405, 2.516e-9,
18.6021, 0.5240207766,
},
[PLANET_JUPITER] = {
100.4542, 2.76854e-5,
1.3030, -1.557e-7,
273.8777, 1.64505e-5,
5.20256, 0.0,
0.048498, 4.469e-9,
19.8950, 0.0830853001,
},
[PLANET_SATURN] = {
113.6634, 2.38980e-5,
2.4886, -1.081e-7,
339.3939, 2.97661e-5,
9.55475, 0.0,
0.055546, -9.499e-9,
316.9670, 0.0334442282,
},
[PLANET_URANUS] = {
74.0005, 1.3978e-5,
0.7733, 1.9e-8,
96.6612, 3.0565e-5,
19.18171, -1.55e-8,
0.047318, 7.45e-9,
142.5905, 0.011725806,
},
[PLANET_NEPTUNE] = {
131.7806, 3.0173e-5,
1.7700, -2.55e-7,
272.8461, -6.027e-6,
30.05826, 3.313e-8,
0.008606, 2.15e-9,
260.2471, 0.005995147,
},
/* Approximate fixed elements (not accurate as fixed linear rates over
* long spans, but Pluto is only ever used as a slow outer planet with
* a wide orb here). */
[PLANET_PLUTO] = {
110.30347, 0.0,
17.14175, 0.0,
113.76329, 0.0,
39.48168677, 0.0,
0.24880766, 0.0,
14.53, 0.00396,
},
};
/* Newton-Raphson sqrt. Fixed iteration count rather than a
* convergence-check loop, so it can't ever fail to terminate. */
static double portable_sqrt(double x) {
if (x <= 0.0) return 0.0;
double guess = (x < 1.0) ? 1.0 : x;
for (int i = 0; i < 50; i++) {
guess = 0.5 * (guess + x / guess);
}
return guess;
}
/* atan(z) for z in [-1,1] via a minimax polynomial (Abramowitz & Stegun
* 4.4.49-style coefficients), max error ~1.2e-5 radians. */
static double portable_atan(double z) {
double z2 = z * z;
return z * (0.9998660 +
z2 * (-0.3302995 +
z2 * (0.1801410 +
z2 * (-0.0851330 + z2 * 0.0208351))));
}
double lowprec_atan2(double y, double x) {
if (x == 0.0 && y == 0.0) return 0.0;
double ax = x < 0.0 ? -x : x;
double ay = y < 0.0 ? -y : y;
double angle;
if (ax >= ay) {
angle = portable_atan(ay / ax);
if (x < 0.0) angle = DECK_PI - angle;
} else {
angle = DECK_PI / 2.0 - portable_atan(ax / ay);
if (x < 0.0) angle = DECK_PI - angle;
}
return (y < 0.0) ? -angle : angle;
}
/* Solves Kepler's equation for the given elements at day-number `d`
* (days since J2000.0), returning rectangular heliocentric (geocentric
* for the Sun/Moon "orbits") ecliptic coordinates in AU (Earth radii for
* the Moon). */
static void solve_orbit(const OrbitalElements *el, double d, double *x, double *y) {
double N = (el->N0 + el->Nd * d) * DEG2RAD;
double i = (el->i0 + el->id * d) * DEG2RAD;
double w = (el->w0 + el->wd * d) * DEG2RAD;
double a = el->a0 + el->ad * d;
double e = el->e0 + el->ed * d;
double M = normalize_deg(el->M0 + el->Md * d) * DEG2RAD;
double E = M + e * lowprec_sin(M) * (1.0 + e * lowprec_cos(M));
for (int iter = 0; iter < 8; iter++) {
double delta = (E - e * lowprec_sin(E) - M) / (1.0 - e * lowprec_cos(E));
E -= delta;
}
double xv = a * (lowprec_cos(E) - e);
double yv = a * (portable_sqrt(1.0 - e * e) * lowprec_sin(E));
double v = lowprec_atan2(yv, xv);
double r = portable_sqrt(xv * xv + yv * yv);
double vw = v + w;
*x = r * (lowprec_cos(N) * lowprec_cos(vw) - lowprec_sin(N) * lowprec_sin(vw) * lowprec_cos(i));
*y = r * (lowprec_sin(N) * lowprec_cos(vw) + lowprec_cos(N) * lowprec_sin(vw) * lowprec_cos(i));
}
/* The dozen largest lunar perturbation terms (Schlyter's "more
* accurate" Moon correction), applied as a direct correction in degrees
* to the Moon's mean-orbit longitude - brings the Moon from several
* degrees of error down to a few arcminutes, worth the modest extra
* code given how often the Moon matters here (a natal luminary, and the
* fastest-moving transiting body). */
static double moon_longitude_correction(double d) {
double Ms = normalize_deg(356.0470 + 0.9856002585 * d) * DEG2RAD; /* Sun mean anomaly */
double Mm = normalize_deg(134.9634 + 13.0649929509 * d) * DEG2RAD; /* Moon mean anomaly */
double Nm = normalize_deg(125.1228 - 0.0529538083 * d) * DEG2RAD; /* Moon's node */
double ws = normalize_deg(282.9404 + 4.70935e-5 * d) * DEG2RAD; /* Sun's perihelion */
double wm = normalize_deg(318.0634 + 0.1643573223 * d) * DEG2RAD; /* Moon's perihelion */
double Ls = ws + Ms; /* Sun's mean longitude */
double Lm = Nm + wm + Mm; /* Moon's mean longitude */
double D = Lm - Ls; /* elongation */
double F = Lm - Nm; /* argument of latitude */
double corr = 0.0;
corr += -1.274 * lowprec_sin(Mm - 2.0 * D);
corr += 0.658 * lowprec_sin(2.0 * D);
corr += -0.186 * lowprec_sin(Ms);
corr += -0.059 * lowprec_sin(2.0 * Mm - 2.0 * D);
corr += -0.057 * lowprec_sin(Mm - 2.0 * D + Ms);
corr += 0.053 * lowprec_sin(Mm + 2.0 * D);
corr += 0.046 * lowprec_sin(2.0 * D - Ms);
corr += 0.041 * lowprec_sin(Mm - Ms);
corr += -0.035 * lowprec_sin(D);
corr += -0.031 * lowprec_sin(Mm + Ms);
corr += -0.015 * lowprec_sin(2.0 * F - 2.0 * D);
corr += 0.011 * lowprec_sin(Mm - 4.0 * D);
return corr;
}
double lowprec_geocentric_longitude(Body body, double jd) {
double d = jd - 2451545.0;
double sun_x, sun_y;
solve_orbit(&k_elements[PLANET_SUN], d, &sun_x, &sun_y);
if (body == PLANET_SUN) {
return normalize_deg(lowprec_atan2(sun_y, sun_x) * RAD2DEG);
}
if (body == PLANET_MOON) {
double moon_x, moon_y;
solve_orbit(&k_elements[PLANET_MOON], d, &moon_x, &moon_y);
double lon = lowprec_atan2(moon_y, moon_x) * RAD2DEG + moon_longitude_correction(d);
return normalize_deg(lon);
}
/* Earth's own heliocentric position is the Sun's geocentric one,
* negated (both "orbits" share the same ecliptic plane by
* definition here, i.e. i = 0 for the Sun's elements). */
double earth_x = -sun_x, earth_y = -sun_y;
double planet_x, planet_y;
solve_orbit(&k_elements[body], d, &planet_x, &planet_y);
double geo_x = planet_x - earth_x;
double geo_y = planet_y - earth_y;
return normalize_deg(lowprec_atan2(geo_y, geo_x) * RAD2DEG);
}
+40
View File
@@ -0,0 +1,40 @@
#ifndef DECK_LOWPREC_EPHEMERIS_H
#define DECK_LOWPREC_EPHEMERIS_H
#include "astro.h"
/* A compact, self-contained low-precision ephemeris for the watch build
* only (PBL_SDK_3) - the vendored Astronomy Engine (astro.c's normal
* desktop path) compiles to ~127KB of code, well over a whole Pebble
* app's entire 64KB code+data+bss budget, so it can't be linked into the
* watchapp at all. This module replaces it there via classic Keplerian
* orbital-element formulas (the widely-published "low precision"
* planetary position method - see lowprec_ephemeris.c's own comment for
* accuracy and sourcing). Not used, and not compiled, on desktop - see
* astro.c's AstroTime typedef and the Makefile's ENGINE_SRCS, which never
* includes this file. */
/* Julian Date (TT and UT are treated as identical at this precision -
* their few-tens-of-seconds difference is far below this method's own
* ~1 arcminute-to-a-few-degrees accuracy) for the given UTC calendar
* date/time. */
double lowprec_julian_date(int year, int month, int day, int hour, int minute, double second);
/* Geocentric apparent ecliptic longitude of `body` at Julian Date `jd`,
* in degrees [0, 360). */
double lowprec_geocentric_longitude(Body body, double jd);
/* Greenwich Mean Sidereal Time at Julian Date `jd`, in hours [0, 24) -
* the watch's replacement for Astronomy_SiderealTime(), used the same
* way (RAMC = gmst*15 + geographic longitude) to compute the Ascendant. */
double lowprec_gmst_hours(double jd);
/* atan2()/sin()/cos() replacements for the watch build - see
* lowprec_ephemeris.c's file-level comment. Exported (not file-static)
* since astro.c's compute_ascendant() also needs them. Same signature/
* semantics as their libm counterparts, in radians. */
double lowprec_atan2(double y, double x);
double lowprec_sin(double rad);
double lowprec_cos(double rad);
#endif
+16 -1
View File
@@ -15,12 +15,16 @@ static void print_usage(const char *prog) {
"Usage: %s --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n"
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
" [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n"
" [--gender male|female|unspecified]\n"
" [--lang <code>] [--i18n-dir <path>]\n\n"
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
" --birth-date/time Birth date/time in local clock time.\n"
" --birth-utc-offset Hours to subtract from local birth time to get UTC (e.g. 2 for CEST).\n"
" --birth-lat/lon Birth location in decimal degrees (north/east positive).\n"
" --date UTC moment for today's transits/tarot draw. Defaults to now.\n"
" --gender Who the reading is for - only affects the Celtic Cross\n"
" position text's pronouns, never the chart/draw itself.\n"
" Defaults to unspecified (pronoun-neutral phrasing).\n"
" --format Output format, defaults to text. html links card art via\n"
" img/<file>, i.e. it expects to be saved next to the img/\n"
" directory that `make images` copies into dist/. json uses\n"
@@ -63,6 +67,7 @@ int main(int argc, char **argv) {
const char *birth_utc_offset = NULL, *birth_lat = NULL, *birth_lon = NULL;
const char *date_str = NULL, *format_str = "text";
const char *lang = "en", *i18n_dir_arg = NULL;
const char *gender_str = "unspecified";
for (int i = 1; i < argc; i++) {
const char *arg = argv[i];
@@ -75,6 +80,7 @@ int main(int argc, char **argv) {
else if (strcmp(arg, "--birth-lon") == 0) rc = require_arg(argc, argv, &i, arg, &birth_lon);
else if (strcmp(arg, "--date") == 0) rc = require_arg(argc, argv, &i, arg, &date_str);
else if (strcmp(arg, "--format") == 0) rc = require_arg(argc, argv, &i, arg, &format_str);
else if (strcmp(arg, "--gender") == 0) rc = require_arg(argc, argv, &i, arg, &gender_str);
else if (strcmp(arg, "--lang") == 0) rc = require_arg(argc, argv, &i, arg, &lang);
else if (strcmp(arg, "--i18n-dir") == 0) rc = require_arg(argc, argv, &i, arg, &i18n_dir_arg);
else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
@@ -136,6 +142,15 @@ int main(int argc, char **argv) {
return 1;
}
Gender gender;
if (strcmp(gender_str, "unspecified") == 0) gender = GENDER_UNSPECIFIED;
else if (strcmp(gender_str, "male") == 0) gender = GENDER_MALE;
else if (strcmp(gender_str, "female") == 0) gender = GENDER_FEMALE;
else {
fprintf(stderr, "Invalid --gender, expected male, female, or unspecified\n");
return 1;
}
char i18n_path[512];
if (i18n_dir_arg) {
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang);
@@ -148,7 +163,7 @@ int main(int argc, char **argv) {
}
DailyReading reading;
reading_generate(seed, &birth, utc_moment, &reading);
reading_generate(seed, &birth, gender, utc_moment, &reading);
if (format == FORMAT_HTML) {
reading_print_html(&reading, stdout);
+24 -11
View File
@@ -3,6 +3,20 @@
#include <math.h>
void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender,
time_t utc_moment, DailyReading *out) {
out->utc_moment = utc_moment;
out->gender = gender;
astro_compute_natal_chart(birth, &out->natal);
astro_compute_daily_transits(utc_moment, &out->natal, &out->transits);
tarot_draw_celtic_cross(tarot_seed, &out->spread);
}
/* Everything below uses fprintf, which Pebble's SDK blocks at compile
* time (see pebble_warn_unsupported_functions.h) - not compiled at all
* for the watch, which only ever calls reading_generate() above. */
#ifndef PBL_SDK_3
/* UI-label keys ("ui.*") are looked up here rather than in i18n.h/.c
* because they're specific to these two renderers, not part of the
* portable astro/tarot vocabulary. */
@@ -10,13 +24,6 @@ static const char *ui(const char *key, const char *fallback) {
return i18n_get(key, fallback);
}
void reading_generate(const char *tarot_seed, const BirthData *birth,
time_t utc_moment, DailyReading *out) {
astro_compute_natal_chart(birth, &out->natal);
astro_compute_daily_transits(utc_moment, &out->natal, &out->transits);
tarot_draw_celtic_cross(tarot_seed, &out->spread);
}
static void print_position_deg(FILE *out, const PlanetPosition *p) {
fprintf(out, "%5.1f%s %-11s (%s %d)", p->degree_in_sign, "\xc2\xb0",
astro_sign_name(p->sign), ui("ui.house", "House"), p->house);
@@ -60,10 +67,10 @@ void reading_print_text(const DailyReading *r, FILE *out) {
fprintf(out, "===== %s =====\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &r->spread.positions[i];
fprintf(out, "%-16s %s%s%s\n", tarot_position_name((CelticCrossPosition)i),
fprintf(out, "%-16s %s%s%s\n", tarot_position_name((CelticCrossPosition)i, r->gender),
tarot_card_name(draw->card), draw->reversed ? " " : "",
draw->reversed ? ui("ui.reversed", "(Reversed)") : "");
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i));
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, r->gender));
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
}
}
@@ -132,12 +139,12 @@ void reading_print_html(const DailyReading *r, FILE *out) {
" <div class=\"desc\">%s</div>\n"
" <div class=\"meaning\">%s</div>\n"
"</div>\n",
tarot_position_name((CelticCrossPosition)i),
tarot_position_name((CelticCrossPosition)i, r->gender),
draw->reversed ? "reversed" : "",
tarot_card_image_file(draw->card), tarot_card_name(draw->card),
tarot_card_name(draw->card), draw->reversed ? " " : "",
draw->reversed ? ui("ui.reversed", "(Reversed)") : "",
tarot_position_description((CelticCrossPosition)i),
tarot_position_description((CelticCrossPosition)i, r->gender),
tarot_card_meaning(draw->card, draw->reversed));
}
fprintf(out, "</div>\n</body></html>\n");
@@ -155,6 +162,10 @@ static void print_body_position_json(FILE *out, const char *indent, Body body,
void reading_print_json(const DailyReading *r, FILE *out) {
fprintf(out, "{\n");
struct tm *utc = gmtime(&r->utc_moment);
fprintf(out, " \"date\": \"%04d-%02d-%02d\",\n", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
fprintf(out, " \"gender\": \"%s\",\n", tarot_gender_slug(r->gender));
fprintf(out, " \"natal\": {\n \"bodies\": [\n");
for (int b = 0; b < NUM_BODIES; b++) {
print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]);
@@ -194,3 +205,5 @@ void reading_print_json(const DailyReading *r, FILE *out) {
}
fprintf(out, " ]\n }\n}\n");
}
#endif /* !PBL_SDK_3 */
+23 -2
View File
@@ -1,13 +1,30 @@
#ifndef DECK_READING_H
#define DECK_READING_H
/* See astro.h's own comment on this same #ifdef - Pebble's SDK
* pre-defines <time.h>'s include guard and declares time_t/struct tm
* itself inside pebble.h. */
#ifdef PBL_SDK_3
#include <pebble.h>
#else
#include <stdio.h>
#include <time.h>
#endif
#include "astro.h"
#include "tarot.h"
typedef struct {
/* The moment passed to reading_generate() - the day this reading is
* for, not when it happened to be generated. Set by reading_generate()
* itself, so every caller (CLI, watch, reading_load_json() on the
* interpreter side) gets it for free without threading it through
* separately. */
time_t utc_moment;
/* Who the reading is for - only affects the pronouns in the Celtic
* Cross position text (see tarot.h's Gender), set by reading_generate()
* the same way utc_moment is, so every caller gets it for free. */
Gender gender;
NatalChart natal;
DailyTransits transits;
CelticCrossSpread spread;
@@ -15,11 +32,14 @@ typedef struct {
/* The real API: this is the only function the watchapp needs to call.
* It walks the returned struct directly to lay out its own screens. */
void reading_generate(const char *tarot_seed, const BirthData *birth,
void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender,
time_t utc_moment, DailyReading *out);
/* Desktop-only output formats for inspecting a reading before any watch
* UI exists. Not used by (and not needed by) the watchapp. */
* UI exists - use fprintf, which Pebble's SDK blocks at compile time, so
* these aren't even declared (and reading.c doesn't define them) when
* building for the watch. Not used by (and not needed by) the watchapp. */
#ifndef PBL_SDK_3
void reading_print_text(const DailyReading *r, FILE *out);
void reading_print_html(const DailyReading *r, FILE *out);
@@ -28,5 +48,6 @@ void reading_print_html(const DailyReading *r, FILE *out);
* tarot_*_name()) so the output is identical regardless of --lang. This
* is the interchange format interpreter/ reads - see its json.c. */
void reading_print_json(const DailyReading *r, FILE *out);
#endif
#endif
+14 -2
View File
@@ -52,6 +52,17 @@ typedef struct {
bool reversed;
} TarotDraw;
/* Who the reading is for - affects only the pronouns in the Celtic
* Cross position text below (Waite's original wording addresses the
* querent as "him"), never anything about the tarot draw or astrology
* math itself. GENDER_UNSPECIFIED is the default and renders
* pronoun-neutral phrasing (singular "they" in English). */
typedef enum {
GENDER_UNSPECIFIED = 0,
GENDER_MALE,
GENDER_FEMALE
} Gender;
typedef struct {
/* Indexed by CelticCrossPosition. */
TarotDraw positions[TAROT_SPREAD_SIZE];
@@ -67,8 +78,8 @@ const char *tarot_card_name(TarotCard card);
const char *tarot_card_image_file(TarotCard card); /* matches res/img/ */
const char *tarot_card_meaning(TarotCard card, bool reversed);
const char *tarot_position_name(CelticCrossPosition position);
const char *tarot_position_description(CelticCrossPosition position);
const char *tarot_position_name(CelticCrossPosition position, Gender gender);
const char *tarot_position_description(CelticCrossPosition position, Gender gender);
/* Stable, language-independent identifiers (e.g. "high_priestess",
* "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup
@@ -76,5 +87,6 @@ const char *tarot_position_description(CelticCrossPosition position);
* (reading_print_json) that must stay identical regardless of --lang. */
const char *tarot_card_slug(TarotCard card);
const char *tarot_position_slug(CelticCrossPosition position);
const char *tarot_gender_slug(Gender gender); /* "unspecified"/"male"/"female" */
#endif
+112 -34
View File
@@ -130,52 +130,97 @@ static const TarotCardInfo k_cards[TAROT_DECK_SIZE] = {
/* Position names and one-line descriptions follow Waite's own account
* of "An Ancient Celtic Method of Divination" (Part III §7), in his
* original drawing order. */
* original drawing order. Waite addresses the querent as "him"
* throughout; that wording is kept verbatim as the GENDER_MALE variant
* below, and GENDER_FEMALE/GENDER_UNSPECIFIED are original paraphrases
* written for this project (not Waite's own text) so the reading can
* address the person it's actually for. Each array is indexed by
* Gender (tarot.h): [GENDER_UNSPECIFIED], [GENDER_MALE], [GENDER_FEMALE]. */
typedef struct {
const char *name;
const char *description;
const char *name[3];
const char *description[3];
} PositionInfo;
static const PositionInfo k_positions[TAROT_SPREAD_SIZE] = {
[POSITION_PRESENT] = {
"The Present",
"This covers him: the general influence affecting the matter."
{ "The Present", "The Present", "The Present" },
{
"This covers them: the general influence affecting the matter.",
"This covers him: the general influence affecting the matter.",
"This covers her: the general influence affecting the matter.",
}
},
[POSITION_CHALLENGE] = {
"The Challenge",
"This crosses him: the nature of the obstacle in the matter."
{ "The Challenge", "The Challenge", "The Challenge" },
{
"This crosses them: the nature of the obstacle in the matter.",
"This crosses him: the nature of the obstacle in the matter.",
"This crosses her: the nature of the obstacle in the matter.",
}
},
[POSITION_CROWN] = {
"The Crown",
"This crowns him: the aim or ideal, the best that can be achieved."
{ "The Crown", "The Crown", "The Crown" },
{
"This crowns them: the aim or ideal, the best that can be achieved.",
"This crowns him: the aim or ideal, the best that can be achieved.",
"This crowns her: the aim or ideal, the best that can be achieved.",
}
},
[POSITION_FOUNDATION] = {
"The Foundation",
"This is beneath him: the basis of the matter, already actual."
{ "The Foundation", "The Foundation", "The Foundation" },
{
"This is beneath them: the basis of the matter, already actual.",
"This is beneath him: the basis of the matter, already actual.",
"This is beneath her: the basis of the matter, already actual.",
}
},
[POSITION_RECENT_PAST] = {
"The Recent Past",
"This is behind him: the influence that is just passing away."
{ "The Recent Past", "The Recent Past", "The Recent Past" },
{
"This is behind them: the influence that is just passing away.",
"This is behind him: the influence that is just passing away.",
"This is behind her: the influence that is just passing away.",
}
},
[POSITION_NEAR_FUTURE] = {
"The Near Future",
"This is before him: the influence now coming into action."
{ "The Near Future", "The Near Future", "The Near Future" },
{
"This is before them: the influence now coming into action.",
"This is before him: the influence now coming into action.",
"This is before her: the influence now coming into action.",
}
},
[POSITION_ATTITUDE] = {
"Himself",
"His position or attitude in the circumstances."
{ "Themself", "Himself", "Herself" },
{
"Their position or attitude in the circumstances.",
"His position or attitude in the circumstances.",
"Her position or attitude in the circumstances.",
}
},
[POSITION_ENVIRONMENT] = {
"His House",
"His environment and the tendencies at work therein."
{ "Their House", "His House", "Her House" },
{
"Their environment and the tendencies at work therein.",
"His environment and the tendencies at work therein.",
"Her environment and the tendencies at work therein.",
}
},
[POSITION_HOPES_AND_FEARS] = {
"Hopes and Fears",
"His hopes or fears in the matter."
{ "Hopes and Fears", "Hopes and Fears", "Hopes and Fears" },
{
"Their hopes or fears in the matter.",
"His hopes or fears in the matter.",
"Her hopes or fears in the matter.",
}
},
[POSITION_OUTCOME] = {
"The Outcome",
"What will come: the final result of the matter."
{ "The Outcome", "The Outcome", "The Outcome" },
{
"What will come: the final result of the matter.",
"What will come: the final result of the matter.",
"What will come: the final result of the matter.",
}
},
};
@@ -229,13 +274,44 @@ static const char *card_field(const char *slug, const char *field, const char *f
return i18n_get(key, fallback);
}
static const char *position_field(const char *slug, const char *field, const char *fallback) {
char key[64];
strcpy(key, "position.");
strcat(key, slug);
strcat(key, ".");
strcat(key, field);
return i18n_get(key, fallback);
/* Suffix appended to the i18n key for the male/female variants - the
* unspecified/neutral variant uses the plain, unsuffixed key so it
* matches the field name callers already know (e.g. "position.
* attitude.name"), same convention as card_field()'s upright/reversed
* field names above. */
static const char *const k_gender_suffix[3] = {
[GENDER_UNSPECIFIED] = "",
[GENDER_MALE] = ".male",
[GENDER_FEMALE] = ".female",
};
static const char *const k_gender_slug[3] = {
[GENDER_UNSPECIFIED] = "unspecified",
[GENDER_MALE] = "male",
[GENDER_FEMALE] = "female",
};
static const char *position_field(const char *slug, const char *field, Gender gender,
const char *fallback) {
char neutral_key[80];
strcpy(neutral_key, "position.");
strcat(neutral_key, slug);
strcat(neutral_key, ".");
strcat(neutral_key, field);
if (gender == GENDER_UNSPECIFIED) return i18n_get(neutral_key, fallback);
/* Not every position's text actually varies by gender (e.g. "The
* Outcome" has no pronoun) - the .lang files only define a ".male"/
* ".female" key where the wording differs. So look up the neutral key
* first and use *that* (translated, if a catalog is loaded) as the
* fallback for the gendered key, rather than jumping straight past a
* loaded translation to the hardcoded English `fallback` whenever the
* gendered key happens to be absent. */
const char *neutral_text = i18n_get(neutral_key, fallback);
char key[88];
strcpy(key, neutral_key);
strcat(key, k_gender_suffix[gender]);
return i18n_get(key, neutral_text);
}
const char *tarot_card_name(TarotCard card) {
@@ -252,13 +328,15 @@ const char *tarot_card_meaning(TarotCard card, bool reversed) {
reversed ? k_cards[card].reversed : k_cards[card].upright);
}
const char *tarot_position_name(CelticCrossPosition position) {
return position_field(k_position_slug[position], "name", k_positions[position].name);
const char *tarot_position_name(CelticCrossPosition position, Gender gender) {
return position_field(k_position_slug[position], "name", gender, k_positions[position].name[gender]);
}
const char *tarot_position_description(CelticCrossPosition position) {
return position_field(k_position_slug[position], "desc", k_positions[position].description);
const char *tarot_position_description(CelticCrossPosition position, Gender gender) {
return position_field(k_position_slug[position], "desc", gender,
k_positions[position].description[gender]);
}
const char *tarot_card_slug(TarotCard card) { return k_card_slug[card]; }
const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; }
const char *tarot_gender_slug(Gender gender) { return k_gender_slug[gender]; }
+39 -4
View File
@@ -72,8 +72,8 @@ static void test_reading_generate_smoke(void) {
};
DailyReading r1, r2;
reading_generate("smoke-test-seed", &birth, 1751500000, &r1);
reading_generate("smoke-test-seed", &birth, 1751500000, &r2);
reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r1);
reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r2);
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
assert(r1.spread.positions[i].card == r2.spread.positions[i].card);
@@ -103,7 +103,7 @@ static void test_reading_print_json_shape(void) {
};
DailyReading r;
reading_generate("smoke-test-seed", &birth, 1751500000, &r);
reading_generate("smoke-test-seed", &birth, GENDER_FEMALE, 1751500000, &r);
FILE *tmp = tmpfile();
assert(tmp);
@@ -120,6 +120,8 @@ static void test_reading_print_json_shape(void) {
* a stray astro_*_name()/tarot_*_name() call leaking translated text
* into what must stay language-independent regardless of --lang). */
assert(buf[0] == '{');
assert(strstr(buf, "\"date\": \"2025-07-02\"") != NULL); /* UTC date of the fixed 1751500000 seed */
assert(strstr(buf, "\"gender\": \"female\"") != NULL);
assert(strstr(buf, "\"natal\"") != NULL);
assert(strstr(buf, "\"transits\"") != NULL);
assert(strstr(buf, "\"spread\"") != NULL);
@@ -154,7 +156,7 @@ static void test_i18n_fallback_and_translation(void) {
assert(strcmp(astro_moon_phase_name(MOON_FULL), "Vollmond") == 0);
assert(strcmp(astro_aspect_name(ASPECT_TRINE), "Trigon") == 0);
assert(strcmp(tarot_card_name(CARD_WORLD), "Die Welt") == 0);
assert(strcmp(tarot_position_name(POSITION_OUTCOME), "Das Ergebnis") == 0);
assert(strcmp(tarot_position_name(POSITION_OUTCOME, GENDER_UNSPECIFIED), "Das Ergebnis") == 0);
/* Loading a file that doesn't exist fails and leaves the previously
* loaded catalog in place, rather than silently clearing it. */
@@ -169,12 +171,45 @@ static void test_i18n_fallback_and_translation(void) {
printf("PASS test_i18n_fallback_and_translation\n");
}
static void test_gendered_position_text(void) {
/* English catalog (loaded by the previous test): "Attitude" is the one
* position whose name itself carries a pronoun. */
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_UNSPECIFIED), "Themself") == 0);
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_MALE), "Himself") == 0);
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_FEMALE), "Herself") == 0);
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_UNSPECIFIED), "them") != NULL);
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_MALE), "him") != NULL);
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_FEMALE), "her") != NULL);
/* The Outcome position has no pronoun in Waite's own text - identical
* across all three genders. */
assert(strcmp(tarot_position_description(POSITION_OUTCOME, GENDER_MALE),
tarot_position_description(POSITION_OUTCOME, GENDER_FEMALE)) == 0);
assert(strcmp(tarot_position_description(POSITION_OUTCOME, GENDER_MALE),
tarot_position_description(POSITION_OUTCOME, GENDER_UNSPECIFIED)) == 0);
bool loaded = i18n_load("engine/i18n/de.lang");
assert(loaded);
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_MALE), "Er selbst") == 0);
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_FEMALE), "Sie selbst") == 0);
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_UNSPECIFIED),
"Die fragende Person selbst") == 0);
assert(strcmp(tarot_position_description(POSITION_ENVIRONMENT, GENDER_MALE),
"Sein Umfeld und die darin wirkenden Tendenzen.") == 0);
loaded = i18n_load("engine/i18n/en.lang"); /* leave the catalog in a known state */
assert(loaded);
printf("PASS test_gendered_position_text\n");
}
int main(void) {
test_tarot_determinism();
test_natal_sun_sign();
test_reading_generate_smoke();
test_reading_print_json_shape();
test_i18n_fallback_and_translation();
test_gendered_position_text();
printf("All smoke tests passed.\n");
return 0;
}
+20 -5
View File
@@ -1,5 +1,8 @@
#include "guidance.h"
#include <stdbool.h>
#include <stdio.h>
/* i18n_get() only - see narrative.c's own comment on why this one extra
* engine header is needed despite guidance.h's otherwise header-only
* engine dependency. */
@@ -137,13 +140,20 @@ static const char *reversed_marker(bool reversed) {
return buf;
}
void guidance_print(FILE *out, const char *indent, const DailyInterpretation *interp,
void guidance_write(char *buf, size_t buf_size, const char *indent, const DailyInterpretation *interp,
const CelticCrossSpread *spread) {
if (buf_size == 0) return;
buf[0] = '\0';
const TarotDraw *attitude = &spread->positions[POSITION_ATTITUDE];
const TarotDraw *outcome = &spread->positions[POSITION_OUTCOME];
/* Chained snprintf() calls, each bounds-checked against the running
* length before the next one runs - see narrative_write()'s matching
* comment for why (never fprintf/sprintf/vsnprintf). */
size_t len;
if (interp->top_item_count == 0) {
fprintf(out, "%s%s\n", indent, no_transit_text(attitude->reversed));
len = (size_t)snprintf(buf, buf_size, "%s%s\n", indent, no_transit_text(attitude->reversed));
} else {
const Aspect *top = &interp->top_items[0].aspect;
TransitCharacter character = classify_transit(top->type);
@@ -152,13 +162,18 @@ void guidance_print(FILE *out, const char *indent, const DailyInterpretation *in
char intro[192];
snprintf(intro, sizeof(intro), intro_template(interp->day_level),
planet_display_name(top->transiting_planet));
fprintf(out, "%s%s %s\n", indent, intro, stance);
len = (size_t)snprintf(buf, buf_size, "%s%s %s\n", indent, intro, stance);
}
if (len >= buf_size) return;
fprintf(out, "%s%s %s%s: %s\n", indent, i18n_get("guidance.attitude_intro", "Your Attitude card is"),
len += (size_t)snprintf(buf + len, buf_size - len, "%s%s %s%s: %s\n", indent,
i18n_get("guidance.attitude_intro", "Your Attitude card is"),
tarot_card_name(attitude->card), reversed_marker(attitude->reversed),
tarot_card_meaning(attitude->card, attitude->reversed));
fprintf(out, "%s%s %s%s: %s\n", indent, i18n_get("guidance.outcome_intro", "The spread's likely Outcome is"),
if (len >= buf_size) return;
snprintf(buf + len, buf_size - len, "%s%s %s%s: %s\n", indent,
i18n_get("guidance.outcome_intro", "The spread's likely Outcome is"),
tarot_card_name(outcome->card), reversed_marker(outcome->reversed),
tarot_card_meaning(outcome->card, outcome->reversed));
}
+20 -7
View File
@@ -1,7 +1,7 @@
#ifndef DECK_GUIDANCE_H
#define DECK_GUIDANCE_H
#include <stdio.h>
#include <stddef.h>
/* Header-only dependency on the engine for struct/enum *definitions*,
* same policy as narrative.h/significance.h - see their comments for
@@ -14,6 +14,13 @@
#include "../../engine/src/tarot.h"
#include "significance.h"
/* Generous upper bound on guidance_write()'s output (intro/stance line
* + Attitude line + Outcome line, each ending in a newline, including
* the longest tarot_card_meaning() strings) across every language this
* project ships a translation for - see narrative.h's NARRATIVE_TEXT_MAX
* for the same reasoning. */
#define GUIDANCE_TEXT_MAX 1024
/* Ties the day's single most significant transit
* (interp->top_items[0]) to the Celtic Cross spread, and prints a short
* paragraph of concrete guidance on how to meet the day - the "combined
@@ -49,12 +56,18 @@
* strings) is looked up via i18n_get() under "guidance.*" keys
* (engine/i18n's .lang files) before falling back to the English text baked
* into guidance.c, so it respects whatever --lang the caller loaded
* with i18n_load() (main.c does so before calling this). The German
* guidance.intro.* templates are deliberately phrased so the planet
* name placeholder is always the sentence's grammatical subject
* (nominative case) across all four day levels - see guidance.c's own
* comment on k_intro_fallback. */
void guidance_print(FILE *out, const char *indent, const DailyInterpretation *interp,
* with i18n_load()/i18n_load_table() (main.c does so before calling
* this). The German guidance.intro.* templates are deliberately phrased
* so the planet name placeholder is always the sentence's grammatical
* subject (nominative case) across all four day levels - see
* guidance.c's own comment on k_intro_fallback.
*
* Writes into `buf` (a caller-supplied buffer of `buf_size` bytes,
* always left null-terminated - GUIDANCE_TEXT_MAX is a safe size)
* instead of a FILE*, using only snprintf() internally (never
* fprintf/sprintf/vsnprintf, all of which Pebble's SDK blocks at compile
* time), so this function links into the watch app unchanged. */
void guidance_write(char *buf, size_t buf_size, const char *indent, const DailyInterpretation *interp,
const CelticCrossSpread *spread);
#endif
+146 -76
View File
@@ -1,5 +1,3 @@
#define _POSIX_C_SOURCE 200809L /* for open_memstream */
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
@@ -128,6 +126,15 @@ static const char *sign_display_name(ZodiacSign sign) {
return ui(key, fallback[sign]);
}
/* "YYYY-MM-DD" for reading->utc_moment - the day this reading is for
* (see reading_load_json()/parse_date() in reading_io.c), not today's
* real date if the two ever differ (e.g. reading.json was generated
* earlier and interpreted later). */
static void format_date(time_t utc_moment, char *out, size_t out_size) {
struct tm *utc = gmtime(&utc_moment);
snprintf(out, out_size, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
}
static const char *day_level_name(DaySignificance level) {
switch (level) {
case DAY_QUIET: return ui("interp.day_level.quiet", "Quiet");
@@ -156,30 +163,57 @@ static void print_body_in_sign_text(FILE *out, const PlanetPosition *pos) {
fprintf(out, " %s", buf);
}
/* Captures narrative_print()/guidance_print()'s output (they only know
* how to write to a FILE*) into a heap string, for embedding into JSON -
* avoids changing either module's public API just for this one caller.
* Strips a single trailing newline; caller must free() the result. */
/* Captures narrative_write()/guidance_write()'s output into a heap
* string, for embedding into JSON - a stack buffer plus a single
* malloc()+memcpy() copy, since main.c doesn't need to stream this
* anywhere. Strips a single trailing newline; caller must free() the
* result. */
static char *capture_narrative(const Aspect *aspect, int house) {
char *buf = NULL;
size_t size = 0;
FILE *mem = open_memstream(&buf, &size);
narrative_print(mem, "", aspect, house);
fclose(mem);
if (size > 0 && buf[size - 1] == '\n') buf[size - 1] = '\0';
char stack_buf[NARRATIVE_TEXT_MAX];
narrative_write(stack_buf, sizeof stack_buf, "", aspect, house);
size_t len = strlen(stack_buf);
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
char *buf = malloc(len + 1);
memcpy(buf, stack_buf, len + 1);
return buf;
}
static char *capture_guidance(const DailyInterpretation *interp, const CelticCrossSpread *spread) {
char *buf = NULL;
size_t size = 0;
FILE *mem = open_memstream(&buf, &size);
guidance_print(mem, "", interp, spread);
fclose(mem);
if (size > 0 && buf[size - 1] == '\n') buf[size - 1] = '\0';
char stack_buf[GUIDANCE_TEXT_MAX];
guidance_write(stack_buf, sizeof stack_buf, "", interp, spread);
size_t len = strlen(stack_buf);
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
char *buf = malloc(len + 1);
memcpy(buf, stack_buf, len + 1);
return buf;
}
/* Prints one significant-transit item's full detail (title line with
* sign/house/orb/score, plus its narrative sentence) - shared between
* the "Significant Transits" list and the day's top item, which is
* repeated in full just above the guidance paragraph (see
* interp_print_text) so the reader has that same detail in view right
* next to the guidance text it informs. `index` is 1-based and prefixes
* the line with "N. "; pass 0 to omit it, for the repeated top item,
* which isn't part of the numbered list. */
static void print_transit_item_text(FILE *out, const DailyReading *reading,
const SignificantItem *item, int index) {
const Aspect *a = &item->aspect;
if (index > 0) fprintf(out, " %d. ", index);
fprintf(out, "%s %s", ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet));
print_body_in_sign_text(out, &reading->transits.bodies[a->transiting_planet]);
fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet));
print_body_in_sign_text(out, &reading->natal.bodies[a->natal_planet]);
fprintf(out, " (%s %.1f\xc2\xb0, %s %.2f)\n", ui("ui.orb", "orb"), a->orb,
ui("interp.score", "score"), item->score);
char narrative_buf[NARRATIVE_TEXT_MAX];
narrative_write(narrative_buf, sizeof narrative_buf, " ", a,
reading->transits.bodies[a->transiting_planet].house);
fputs(narrative_buf, out);
}
static void print_top_transits_header(FILE *out, int count) {
if (count == 1) {
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:"));
@@ -192,18 +226,22 @@ static void print_top_transits_header(FILE *out, int count) {
/* ===== --format text ===== */
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread) {
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread, Gender gender) {
fprintf(out, "%s:\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &spread->positions[i];
fprintf(out, " %d. %s: %s%s\n", i + 1, tarot_position_name((CelticCrossPosition)i),
fprintf(out, " %d. %s: %s%s\n", i + 1, tarot_position_name((CelticCrossPosition)i, gender),
tarot_card_name(draw->card), reversed_marker(draw->reversed));
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i));
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, gender));
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
}
}
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, "%s %s\n\n", ui("interp.reading_date", "Reading for:"), date_buf);
fprintf(out, "%s %s (%d/%d)\n", ui("interp.day_significance", "Day significance:"),
day_level_name(interp->day_level), interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
if (interpretation_deserves_framing(interp)) {
@@ -212,35 +250,69 @@ static void interp_print_text(FILE *out, const DailyReading *reading, const Dail
}
fprintf(out, "\n");
fprintf(out, "%s\n", ui("interp.heading_guidance", "Guidance"));
if (interp->top_item_count > 0) {
print_transit_item_text(out, reading, &interp->top_items[0], 0);
}
char guidance_buf[GUIDANCE_TEXT_MAX];
guidance_write(guidance_buf, sizeof guidance_buf, "", interp, &reading->spread);
fputs(guidance_buf, out);
fprintf(out, "\n");
if (interp->top_item_count == 0) {
fprintf(out, "%s\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
print_top_transits_header(out, interp->top_item_count);
for (int i = 0; i < interp->top_item_count; i++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect;
fprintf(out, " %d. %s %s", i + 1, ui("ui.transiting", "Transiting"),
body_display_name(a->transiting_planet));
print_body_in_sign_text(out, &reading->transits.bodies[a->transiting_planet]);
fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet));
print_body_in_sign_text(out, &reading->natal.bodies[a->natal_planet]);
fprintf(out, " (%s %.1f\xc2\xb0, %s %.2f)\n", ui("ui.orb", "orb"), a->orb,
ui("interp.score", "score"), item->score);
narrative_print(out, " ", a, reading->transits.bodies[a->transiting_planet].house);
print_transit_item_text(out, reading, &interp->top_items[i], i + 1);
}
}
fprintf(out, "\n");
print_celtic_cross_text(out, &reading->spread);
fprintf(out, "\n");
guidance_print(out, "", interp, &reading->spread);
print_celtic_cross_text(out, &reading->spread, reading->gender);
}
/* ===== --format html ===== */
/* HTML counterpart of print_transit_item_text() - same shared-between-
* the-repeated-top-item-and-the-full-list purpose, one table row (title
* + orb/score) plus a narrative row if non-empty. `index` is 1-based;
* pass 0 to omit the "N." cell, for the repeated top item. */
static void print_transit_item_html(FILE *out, const DailyReading *reading,
const SignificantItem *item, int index) {
const Aspect *a = &item->aspect;
char index_buf[16] = "";
if (index > 0) snprintf(index_buf, sizeof index_buf, "%d.", index);
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
fprintf(out, "<tr><td>%s</td><td>%s %s %s %s %s</td>"
"<td>%s %.1f&deg;, %s %.2f</td></tr>\n",
index_buf, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet),
aspect_display_name(a->type), ui("ui.natal", "natal"), body_display_name(a->natal_planet),
ui("ui.orb", "orb"), a->orb, ui("interp.score", "score"), item->score);
if (text[0] != '\0') fprintf(out, "<tr><td></td><td colspan=\"2\">%s</td></tr>\n", text);
free(text);
}
/* One Celtic Cross card as a ".card" div - shared between the
* Attitude/Outcome preview (right after guidance) and the full spread
* further down, so both render identically. */
static void print_card_html(FILE *out, CelticCrossPosition position, const TarotDraw *draw,
Gender gender) {
fprintf(out,
"<div class=\"card\">\n"
" <div class=\"position\">%s</div>\n"
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
" <div class=\"name\">%s%s</div>\n"
" <div class=\"desc\">%s</div>\n"
" <div class=\"meaning\">%s</div>\n"
"</div>\n",
tarot_position_name(position, gender), draw->reversed ? "reversed" : "",
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card),
reversed_marker(draw->reversed), tarot_position_description(position, gender),
tarot_card_meaning(draw->card, draw->reversed));
}
static void interp_print_html(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out,
"<!doctype html>\n<html><head><meta charset=\"utf-8\">\n"
@@ -262,6 +334,11 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
fprintf(out, "<h1>%s</h1>\n", ui("interp.heading", "Daily Interpretation"));
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, "<p class=\"reading-date\">%s %s</p>\n",
ui("interp.reading_date", "Reading for:"), date_buf);
fprintf(out, "<h2>%s</h2>\n<p>%s %s (%d/%d)",
ui("interp.heading_day_significance", "Day Significance"),
ui("interp.day_significance", "Day significance:"), day_level_name(interp->day_level),
@@ -272,48 +349,20 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
}
fprintf(out, "</p>\n");
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits"));
if (interp->top_item_count == 0) {
fprintf(out, "<p>%s</p>\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
fprintf(out, "<table>\n");
for (int i = 0; i < interp->top_item_count; i++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect;
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
char *guidance = capture_guidance(interp, &reading->spread);
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_guidance", "Guidance"));
fprintf(out, "<tr><td>%d.</td><td>%s %s %s %s %s</td>"
"<td>%s %.1f&deg;, %s %.2f</td></tr>\n",
i + 1, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet),
aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet), ui("ui.orb", "orb"), a->orb,
ui("interp.score", "score"), item->score);
if (text[0] != '\0') fprintf(out, "<tr><td></td><td colspan=\"2\">%s</td></tr>\n", text);
free(text);
}
fprintf(out, "</table>\n");
}
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &reading->spread.positions[i];
fprintf(out,
"<div class=\"card\">\n"
" <div class=\"position\">%s</div>\n"
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
" <div class=\"name\">%s%s</div>\n"
" <div class=\"desc\">%s</div>\n"
" <div class=\"meaning\">%s</div>\n"
"</div>\n",
tarot_position_name((CelticCrossPosition)i), draw->reversed ? "reversed" : "",
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card),
reversed_marker(draw->reversed), tarot_position_description((CelticCrossPosition)i),
tarot_card_meaning(draw->card, draw->reversed));
}
fprintf(out, "<div class=\"spread attitude-outcome\">\n");
print_card_html(out, POSITION_ATTITUDE, &reading->spread.positions[POSITION_ATTITUDE], reading->gender);
print_card_html(out, POSITION_OUTCOME, &reading->spread.positions[POSITION_OUTCOME], reading->gender);
fprintf(out, "</div>\n");
char *guidance = capture_guidance(interp, &reading->spread);
fprintf(out, "<h2>%s</h2>\n<div class=\"guidance\">\n", ui("interp.heading_guidance", "Guidance"));
fprintf(out, "<div class=\"guidance\">\n");
if (interp->top_item_count > 0) {
fprintf(out, "<table>\n");
print_transit_item_html(out, reading, &interp->top_items[0], 0);
fprintf(out, "</table>\n");
}
const char *start = guidance;
for (const char *p = guidance; ; p++) {
if (*p == '\n' || *p == '\0') {
@@ -325,6 +374,23 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
free(guidance);
fprintf(out, "</div>\n");
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits"));
if (interp->top_item_count == 0) {
fprintf(out, "<p>%s</p>\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
fprintf(out, "<table>\n");
for (int i = 0; i < interp->top_item_count; i++) {
print_transit_item_html(out, reading, &interp->top_items[i], i + 1);
}
fprintf(out, "</table>\n");
}
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
print_card_html(out, (CelticCrossPosition)i, &reading->spread.positions[i], reading->gender);
}
fprintf(out, "</div>\n");
fprintf(out, "</body></html>\n");
}
@@ -351,6 +417,10 @@ static void json_string(FILE *out, const char *s) {
static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out, "{\n");
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, " \"date\": \"%s\",\n", date_buf);
fprintf(out, " \"day_significance\": {\n");
fprintf(out, " \"level\": \"%s\",\n", k_day_level_slug[interp->day_level]);
fprintf(out, " \"rank\": %d,\n", interp->day_level + 1);
+24 -6
View File
@@ -136,8 +136,11 @@ static const char *narrative_field(const char *slug, const char *field, const ch
return i18n_get(key, fallback);
}
void narrative_print(FILE *out, const char *indent, const Aspect *aspect,
void narrative_write(char *buf, size_t buf_size, const char *indent, const Aspect *aspect,
int transiting_house) {
if (buf_size == 0) return;
buf[0] = '\0';
const TransitNarrative *n = &k_narratives[aspect->transiting_planet];
const char *base = narrative_field(n->slug, "base", n->base);
@@ -147,7 +150,15 @@ void narrative_print(FILE *out, const char *indent, const Aspect *aspect,
if (base[0] == '\0' && extra[0] == '\0') return;
fprintf(out, "%s", indent);
/* Chained snprintf() calls, each bounds-checked against the running
* length before the next one runs - never fprintf/sprintf/vsnprintf,
* all of which Pebble's SDK blocks at compile time (see narrative.h's
* doc comment). Once `len` reaches buf_size (truncated), every
* subsequent call is skipped rather than computing buf_size - len,
* which would underflow. */
size_t len = (size_t)snprintf(buf, buf_size, "%s", indent);
if (len >= buf_size) return;
if (transiting_house >= 1 && transiting_house <= 12) {
char house_key[24];
snprintf(house_key, sizeof house_key, "narrative.house.%d", transiting_house);
@@ -156,9 +167,16 @@ void narrative_print(FILE *out, const char *indent, const Aspect *aspect,
char frame[256];
snprintf(frame, sizeof frame, i18n_get("narrative.house_frame", "In matters of %s (house %d)."),
area, transiting_house);
fprintf(out, "%s ", frame);
len += (size_t)snprintf(buf + len, buf_size - len, "%s ", frame);
if (len >= buf_size) return;
}
if (base[0] != '\0') fprintf(out, "%s", base);
if (extra[0] != '\0') fprintf(out, "%s%s", base[0] != '\0' ? " " : "", extra);
fprintf(out, "\n");
if (base[0] != '\0') {
len += (size_t)snprintf(buf + len, buf_size - len, "%s", base);
if (len >= buf_size) return;
}
if (extra[0] != '\0') {
len += (size_t)snprintf(buf + len, buf_size - len, "%s%s", base[0] != '\0' ? " " : "", extra);
if (len >= buf_size) return;
}
snprintf(buf + len, buf_size - len, "\n");
}
+20 -8
View File
@@ -1,12 +1,20 @@
#ifndef DECK_NARRATIVE_H
#define DECK_NARRATIVE_H
#include <stdio.h>
#include <stddef.h>
/* Header-only dependency on the engine, same policy as significance.h -
* see its comment for why. */
#include "../../engine/src/astro.h"
/* Generous upper bound on narrative_write()'s output (indent + house
* framing + base + harmonious/discordant addendum + newline) across
* every language this project ships a translation for - callers on
* constrained platforms (the watch) are free to size their own stack
* buffer smaller if they know their own strings are shorter; this is
* just a safe default for a caller that doesn't want to think about it. */
#define NARRATIVE_TEXT_MAX 512
/* Condensed, public-domain descriptions of what each transiting planet
* classically signifies, sourced from Sepharial's "Transits and
* Planetary Periods" (1920, public domain - res/Transits_and_Planetary_
@@ -22,15 +30,19 @@
* narrative.c is original, written for this project in a matching
* voice, not drawn from Sepharial.
*
* Prints a narrative sentence for `aspect`'s transiting planet to `out`,
* indented with `indent` and followed by a newline - or prints nothing
* if the source material has no text applicable to this planet/aspect
* Writes a narrative sentence for `aspect`'s transiting planet into
* `buf` (a caller-supplied buffer of `buf_size` bytes, always left
* null-terminated - NARRATIVE_TEXT_MAX is a safe size), indented with
* `indent` and followed by a newline - or writes an empty string if the
* source material has no text applicable to this planet/aspect
* combination (e.g. an exactly neutral conjunction to a planet whose
* only text is a "well/badly aspected" addendum, like the Sun). A
* conjunction is treated as neutral - neither the "harmonious" nor the
* "discordant" addendum is shown for it - since Sepharial's own text
* says a conjunction "takes the nature of what it conjoins," something
* this data doesn't model.
* this data doesn't model. Uses only snprintf() internally (never
* fprintf/sprintf/vsnprintf, all of which Pebble's SDK blocks at compile
* time), so this function links into the watch app unchanged.
*
* `transiting_house` is the whole-sign house (1-12) the transiting
* planet currently occupies in the natal chart - i.e.
@@ -44,9 +56,9 @@
* Every string here is looked up via i18n_get() under "narrative.*"
* keys (engine/i18n's .lang files) before falling back to the English text
* baked into narrative.c, the same catalog tarot_data.c uses - so this
* text respects whatever --lang the caller loaded with i18n_load()
* (main.c does so before calling this). */
void narrative_print(FILE *out, const char *indent, const Aspect *aspect,
* text respects whatever --lang the caller loaded with i18n_load()/
* i18n_load_table() (main.c does so before calling this). */
void narrative_write(char *buf, size_t buf_size, const char *indent, const Aspect *aspect,
int transiting_house);
#endif
+53
View File
@@ -1,6 +1,9 @@
#define _DEFAULT_SOURCE /* for timegm, parsing "date" back into utc_moment */
#include "reading_io.h"
#include "json.h"
#include <stdio.h>
#include <string.h>
/* Mirrors astro.c's own k_body_slug/k_aspect_slug (its i18n lookup-key
@@ -36,6 +39,12 @@ static const char *const k_position_slug[TAROT_SPREAD_SIZE] = {
"near_future", "attitude", "environment", "hopes_and_fears", "outcome",
};
/* Mirrors tarot_data.c's own k_gender_slug, same duplication policy as
* k_card_slug/k_position_slug above (no reverse lookup exposed there). */
static const char *const k_gender_slug[3] = {
"unspecified", "male", "female",
};
static bool body_from_slug(const char *slug, Body *out) {
for (int i = 0; i < NUM_BODIES; i++) {
if (strcmp(slug, k_body_slug[i]) == 0) {
@@ -101,6 +110,16 @@ static bool card_from_slug(const char *slug, TarotCard *out) {
return false;
}
static bool gender_from_slug(const char *slug, Gender *out) {
for (int i = 0; i < 3; i++) {
if (strcmp(slug, k_gender_slug[i]) == 0) {
*out = (Gender)i;
return true;
}
}
return false;
}
static bool position_from_slug(const char *slug, CelticCrossPosition *out) {
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
if (strcmp(slug, k_position_slug[i]) == 0) {
@@ -133,6 +152,37 @@ static void parse_spread(const JsonValue *positions, CelticCrossSpread *out) {
}
}
/* Parses the top-level "date" field ("YYYY-MM-DD", see reading_print_json
* in engine/src/reading.c) back into out->utc_moment, as midnight UTC of
* that date - reading_print_json only ever wrote the calendar date, not a
* time of day, so that's the only value that round-trips through
* gmtime() to the same date. Lenient like parse_bodies()/parse_spread():
* a missing or malformed "date" just leaves out->utc_moment at 0 (from
* reading_load_json's own memset), not a load failure - nothing here
* depends on it for scoring, only for display. */
static void parse_date(const JsonValue *date, DailyReading *out) {
const char *date_str = date ? json_as_string(date) : NULL;
if (!date_str) return;
struct tm tm_date = {0};
if (sscanf(date_str, "%d-%d-%d", &tm_date.tm_year, &tm_date.tm_mon, &tm_date.tm_mday) != 3) return;
tm_date.tm_year -= 1900;
tm_date.tm_mon -= 1;
out->utc_moment = timegm(&tm_date);
}
/* Parses the top-level "gender" field (see reading_print_json in
* engine/src/reading.c) into out->gender. Lenient like parse_date(): a
* missing or unrecognized value just leaves out->gender at
* GENDER_UNSPECIFIED (0, from reading_load_json's own memset), not a
* load failure - it only affects Celtic Cross position text pronouns,
* nothing scoring depends on it. */
static void parse_gender(const JsonValue *gender, DailyReading *out) {
const char *gender_str = gender ? json_as_string(gender) : NULL;
if (!gender_str) return;
gender_from_slug(gender_str, &out->gender);
}
bool reading_load_json(const char *text, size_t length, DailyReading *out) {
memset(out, 0, sizeof(*out));
@@ -174,6 +224,9 @@ bool reading_load_json(const char *text, size_t length, DailyReading *out) {
}
out->transits.aspect_count = filled;
parse_date(json_object_get(root, "date"), out);
parse_gender(json_object_get(root, "gender"), out);
const JsonValue *natal = json_object_get(root, "natal");
parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies);
parse_bodies(json_object_get(transits, "bodies"), out->transits.bodies);
+8 -3
View File
@@ -14,10 +14,15 @@
* around each significant event, and for guidance_print()'s tarot
* framing: out->transits.aspects[]/aspect_count (used for scoring - see
* significance.c), out->natal.bodies[]/out->transits.bodies[] (sign +
* house per body, used only for display), and out->spread.positions[]
* house per body, used only for display), out->spread.positions[]
* (card + reversed per Celtic Cross position, used only by
* guidance.c). out->natal.ascendant_longitude/houses[] are left zeroed -
* nothing reads them yet.
* guidance.c), out->utc_moment (the top-level "date" field, parsed
* back to midnight UTC of that date - used only for display, e.g. main.c
* printing which day a reading is for), and out->gender (the top-level
* "gender" field - used only for the Celtic Cross position text's
* pronouns, e.g. tarot_position_name()/tarot_position_description()).
* out->natal.ascendant_longitude/houses[] are left zeroed - nothing
* reads them yet.
*
* Returns false on malformed JSON, or if "transits"."aspects" isn't
* present as an array (an empty array is fine - that's a real "no
+20 -46
View File
@@ -1,6 +1,7 @@
#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -9,15 +10,6 @@
#include "../../engine/src/i18n.h"
#include "../src/guidance.h"
static const char *slurp(FILE *f) {
static char buf[4096];
long len = ftell(f);
rewind(f);
size_t n = fread(buf, 1, (size_t)len, f);
buf[n] = '\0';
return buf;
}
static DailyInterpretation make_interp(DaySignificance level, AspectType type, Body transiting) {
DailyInterpretation interp;
interp.top_item_count = 1;
@@ -51,9 +43,8 @@ static CelticCrossSpread make_spread(TarotCard attitude_card, bool attitude_reve
static void test_harmonious_upright(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_TRINE, PLANET_JUPITER);
CelticCrossSpread spread = make_spread(CARD_SUN, false, CARD_WORLD, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "already meeting the day in the right spirit") != NULL);
assert(strstr(text, "Jupiter") != NULL);
@@ -61,39 +52,34 @@ static void test_harmonious_upright(void) {
assert(strstr(text, "The World: Assured success") != NULL);
assert(strstr(text, "(Reversed)") == NULL);
fclose(f);
printf("PASS test_harmonious_upright\n");
}
static void test_discordant_reversed(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_SQUARE, PLANET_SATURN);
CelticCrossSpread spread = make_spread(CARD_DEVIL, true, CARD_WORLD, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "Both the day and your own footing are unsettled") != NULL);
assert(strstr(text, "Saturn") != NULL);
assert(strstr(text, "The Devil (Reversed): Evil fatality") != NULL);
assert(strstr(text, "The World: Assured success") != NULL);
fclose(f);
printf("PASS test_discordant_reversed\n");
}
static void test_neutral_conjunction_upright(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_CONJUNCTION, PLANET_PLUTO);
CelticCrossSpread spread = make_spread(CARD_EMPEROR, false, CARD_MOON, true);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "Today concentrates whatever you already bring to it") != NULL);
assert(strstr(text, "Pluto") != NULL);
assert(strstr(text, "The Emperor: Stability, power") != NULL);
assert(strstr(text, "The Moon (Reversed): Instability, inconstancy") != NULL);
fclose(f);
printf("PASS test_neutral_conjunction_upright\n");
}
@@ -104,74 +90,64 @@ static void test_neutral_conjunction_upright(void) {
static void test_significant_day_uses_significant_intro(void) {
DailyInterpretation interp = make_interp(DAY_SIGNIFICANT, ASPECT_SEXTILE, PLANET_VENUS);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "clearly active today") != NULL);
assert(strstr(text, "Venus") != NULL);
fclose(f);
printf("PASS test_significant_day_uses_significant_intro\n");
}
static void test_notable_day_uses_notable_intro(void) {
DailyInterpretation interp = make_interp(DAY_NOTABLE, ASPECT_SEXTILE, PLANET_MARS);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "mild touch from Mars") != NULL);
fclose(f);
printf("PASS test_notable_day_uses_notable_intro\n");
}
static void test_quiet_day_with_a_faint_item_still_names_it(void) {
DailyInterpretation interp = make_interp(DAY_QUIET, ASPECT_SEXTILE, PLANET_MERCURY);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "only faintly active today") != NULL);
assert(strstr(text, "Mercury") != NULL);
fclose(f);
printf("PASS test_quiet_day_with_a_faint_item_still_names_it\n");
}
static void test_no_aspects_at_all_falls_back_to_attitude_only(void) {
DailyInterpretation interp = make_empty_interp();
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "astrologically quiet day") != NULL);
assert(strstr(text, "trust your current footing") != NULL);
assert(strstr(text, "The Star: Loss, theft") != NULL);
assert(strstr(text, "The Sun: Material happiness") != NULL);
fclose(f);
printf("PASS test_no_aspects_at_all_falls_back_to_attitude_only\n");
}
static void test_no_aspects_at_all_respects_reversed_attitude(void) {
DailyInterpretation interp = make_empty_interp();
CelticCrossSpread spread = make_spread(CARD_STAR, true, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "quietly re-settle your own footing") != NULL);
fclose(f);
printf("PASS test_no_aspects_at_all_respects_reversed_attitude\n");
}
/* Proves guidance_print() actually routes through i18n_get() rather
/* Proves guidance_write() actually routes through i18n_get() rather
* than just printing the hardcoded fallback. Run last: i18n_load()
* replaces the process-global catalog for the rest of the binary's
* lifetime. */
@@ -191,16 +167,14 @@ static void test_translation_catalog_overrides_fallback_text(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_SQUARE, PLANET_SATURN);
CelticCrossSpread spread = make_spread(CARD_SUN, false, CARD_WORLD, false);
FILE *out = tmpfile();
guidance_print(out, "", &interp, &spread);
const char *text = slurp(out);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "UEBERSETZTE HALTUNG") != NULL);
assert(strstr(text, "UEBERSETZTE EINLEITUNG UEBERSETZTER SATURN") != NULL);
assert(strstr(text, "UEBERSETZTE HALTUNGSKARTE") != NULL);
assert(strstr(text, "Your instincts are sound") == NULL); /* English fallback must not leak through */
fclose(out);
printf("PASS test_translation_catalog_overrides_fallback_text\n");
}
+64
View File
@@ -1,6 +1,7 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include "../src/json.h"
#include "../src/reading_io.h"
@@ -48,6 +49,8 @@ static void test_json_parse_rejects_malformed(void) {
* absent entirely, to prove partial spread data doesn't fail the load. */
static const char *k_fixture =
"{"
" \"date\": \"2026-07-16\","
" \"gender\": \"female\","
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []},"
" \"transits\": {"
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}],"
@@ -80,6 +83,62 @@ static void test_reading_load_json_extracts_aspects(void) {
printf("PASS test_reading_load_json_extracts_aspects\n");
}
static void test_reading_load_json_extracts_date(void) {
DailyReading reading;
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
assert(ok);
struct tm *utc = gmtime(&reading.utc_moment);
assert(utc->tm_year + 1900 == 2026);
assert(utc->tm_mon + 1 == 7);
assert(utc->tm_mday == 16);
printf("PASS test_reading_load_json_extracts_date\n");
}
static void test_reading_load_json_missing_date_defaults_to_zero(void) {
const char *text = "{\"transits\": {\"aspects\": []}}";
DailyReading reading;
bool ok = reading_load_json(text, strlen(text), &reading);
assert(ok);
assert(reading.utc_moment == 0);
printf("PASS test_reading_load_json_missing_date_defaults_to_zero\n");
}
static void test_reading_load_json_extracts_gender(void) {
DailyReading reading;
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
assert(ok);
assert(reading.gender == GENDER_FEMALE);
printf("PASS test_reading_load_json_extracts_gender\n");
}
static void test_reading_load_json_missing_gender_defaults_to_unspecified(void) {
const char *text = "{\"transits\": {\"aspects\": []}}";
DailyReading reading;
bool ok = reading_load_json(text, strlen(text), &reading);
assert(ok);
assert(reading.gender == GENDER_UNSPECIFIED);
printf("PASS test_reading_load_json_missing_gender_defaults_to_unspecified\n");
}
static void test_reading_load_json_unrecognized_gender_defaults_to_unspecified(void) {
const char *text = "{\"gender\": \"xenu\", \"transits\": {\"aspects\": []}}";
DailyReading reading;
bool ok = reading_load_json(text, strlen(text), &reading);
assert(ok);
assert(reading.gender == GENDER_UNSPECIFIED);
printf("PASS test_reading_load_json_unrecognized_gender_defaults_to_unspecified\n");
}
static void test_reading_load_json_extracts_body_sign_and_house(void) {
DailyReading reading;
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
@@ -178,6 +237,11 @@ int main(void) {
test_json_parse_basic_shapes();
test_json_parse_rejects_malformed();
test_reading_load_json_extracts_aspects();
test_reading_load_json_extracts_date();
test_reading_load_json_missing_date_defaults_to_zero();
test_reading_load_json_extracts_gender();
test_reading_load_json_missing_gender_defaults_to_unspecified();
test_reading_load_json_unrecognized_gender_defaults_to_unspecified();
test_reading_load_json_extracts_body_sign_and_house();
test_reading_load_json_extracts_spread_positions();
test_reading_load_json_missing_spread_is_not_fatal();
+19 -44
View File
@@ -9,115 +9,92 @@
#include "../../engine/src/i18n.h"
#include "../src/narrative.h"
static const char *slurp(FILE *f) {
static char buf[4096];
long len = ftell(f);
rewind(f);
size_t n = fread(buf, 1, (size_t)len, f);
buf[n] = '\0';
return buf;
}
static void test_hard_aspect_shows_base_but_not_harmonious_bonus(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_SQUARE, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0);
const char *text = slurp(f);
char text[NARRATIVE_TEXT_MAX];
narrative_write(text, sizeof text, " ", &a, 0);
assert(strstr(text, "depression") != NULL);
assert(strstr(text, "past associations") == NULL);
fclose(f);
printf("PASS test_hard_aspect_shows_base_but_not_harmonious_bonus\n");
}
static void test_soft_aspect_adds_harmonious_bonus(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
.type = ASPECT_TRINE, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0);
const char *text = slurp(f);
char text[NARRATIVE_TEXT_MAX];
narrative_write(text, sizeof text, " ", &a, 0);
assert(strstr(text, "depression") != NULL);
assert(strstr(text, "past associations") != NULL);
fclose(f);
printf("PASS test_soft_aspect_adds_harmonious_bonus\n");
}
static void test_conjunction_is_neutral_prints_base_only(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0);
const char *text = slurp(f);
char text[NARRATIVE_TEXT_MAX];
narrative_write(text, sizeof text, " ", &a, 0);
assert(strstr(text, "depression") != NULL);
assert(strstr(text, "past associations") == NULL);
fclose(f);
printf("PASS test_conjunction_is_neutral_prints_base_only\n");
}
/* The Sun has no unconditional base text (see narrative.c) - a neutral
* conjunction to it should print nothing at all. */
* conjunction to it should write nothing at all. */
static void test_empty_base_and_neutral_aspect_prints_nothing(void) {
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0);
long len = ftell(f);
char text[NARRATIVE_TEXT_MAX];
narrative_write(text, sizeof text, " ", &a, 0);
assert(len == 0);
assert(text[0] == '\0');
fclose(f);
printf("PASS test_empty_base_and_neutral_aspect_prints_nothing\n");
}
static void test_discordant_aspect_on_empty_base_planet(void) {
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
.type = ASPECT_OPPOSITION, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0);
const char *text = slurp(f);
char text[NARRATIVE_TEXT_MAX];
narrative_write(text, sizeof text, " ", &a, 0);
assert(strstr(text, "Degradation") != NULL);
fclose(f);
printf("PASS test_discordant_aspect_on_empty_base_planet\n");
}
static void test_known_house_adds_area_framing(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_SQUARE, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 3);
const char *text = slurp(f);
char text[NARRATIVE_TEXT_MAX];
narrative_write(text, sizeof text, " ", &a, 3);
assert(strstr(text, "house 3") != NULL);
assert(strstr(text, "communication") != NULL);
assert(strstr(text, "depression") != NULL);
fclose(f);
printf("PASS test_known_house_adds_area_framing\n");
}
static void test_unknown_house_omits_area_framing(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_SQUARE, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0); /* 0 = "no data" sentinel, not a real house */
const char *text = slurp(f);
char text[NARRATIVE_TEXT_MAX];
narrative_write(text, sizeof text, " ", &a, 0); /* 0 = "no data" sentinel, not a real house */
assert(strstr(text, "house") == NULL);
assert(strstr(text, "depression") != NULL);
fclose(f);
printf("PASS test_unknown_house_omits_area_framing\n");
}
/* Proves narrative_print() actually routes through i18n_get() rather
/* Proves narrative_write() actually routes through i18n_get() rather
* than just printing the hardcoded fallback - loads a translation file
* that overrides one planet's base text and one house's area text, and
* checks the override wins. Run last: i18n_load() replaces the
@@ -136,15 +113,13 @@ static void test_translation_catalog_overrides_fallback_text(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
FILE *out = tmpfile();
narrative_print(out, "", &a, 3);
const char *text = slurp(out);
char text[NARRATIVE_TEXT_MAX];
narrative_write(text, sizeof text, "", &a, 3);
assert(strstr(text, "UEBERSETZTER SATURN TEXT") != NULL);
assert(strstr(text, "UEBERSETZTES HAUS DREI") != NULL);
assert(strstr(text, "depression") == NULL); /* English fallback must not leak through */
fclose(out);
printf("PASS test_translation_catalog_overrides_fallback_text\n");
}
+7
View File
@@ -0,0 +1,7 @@
# Copy to registry.env (gitignored, never commit the real values) and
# fill in your cr.ladkau.de credentials. Used by build-image.sh,
# run-image.sh, and upload-image.sh.
REGISTRY=cr.ladkau.de
REGISTRY_IMAGE=cr.ladkau.de/deck-in-a-dash/builder
REGISTRY_USER=
REGISTRY_PASSWORD=
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.
Executable
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Runs `make test package watchapp` inside the local build-image
# container, mirroring what the Gitea Actions build workflow does -
# useful for testing build-image changes locally before pushing
# anything to cr.ladkau.de.
#
# Usage: ./run-image.sh [VERSION]
# VERSION is passed through to `make` (see the Makefile's `package`/
# `watchapp` targets); omit it for their own git-tag-based default.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/registry.env" ] \
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
# shellcheck disable=SC1091
source "$ROOT/registry.env"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
IMAGE_TAG="$(<"$ROOT/build-image/VERSION")"
[ -n "$IMAGE_TAG" ] || fail "build-image/VERSION is empty"
IMAGE="$REGISTRY_IMAGE:$IMAGE_TAG"
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|| fail "$IMAGE not found locally — run ./build-image.sh first"
VERSION="${1:-${VERSION:-}}"
# CI always starts from a fresh checkout, but this script bind-mounts the
# live host repo — so build output left over from a previous local run
# (build/, dist/, watch/build, and the generated watch resources below)
# would make this run skip work a real fresh checkout wouldn't, silently
# hiding bugs that only show up in CI. Wipe them first so every run
# exercises a true from-scratch build, same as CI.
echo "== Cleaning generated build artifacts for a fresh build =="
rm -rf \
"$ROOT/build" "$ROOT/dist" "$ROOT/watch/build" \
"$ROOT/watch/resources/img" "$ROOT/watch/resources/app_icon.png" \
"$ROOT/watch/src/c/i18n_tables.auto.c"
echo "== Running make test package watchapp inside $IMAGE =="
# The container runs as root (needed for the toolchain baked into the
# image), so anything it writes into this bind mount — dist/, build/,
# watch/build, etc. — would otherwise come back owned by root, leaving
# the host repo unusable without sudo. Chown everything back to the host
# user on exit, whether the build succeeds or fails.
docker run --rm \
-v "$ROOT:/workspace" \
-w /workspace \
-e VERSION="$VERSION" \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
"$IMAGE" \
bash -c '
git config --global --add safe.directory /workspace
trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT
make test package watchapp
'
echo "== Done — artifacts in dist/ =="
ls -la "$ROOT/dist"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Pushes the build environment image — already built locally with
# ./build-image.sh, and ideally verified with ./run-image.sh — to
# cr.ladkau.de. The Gitea Actions build workflow pulls this image to run
# `make test package watchapp`.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/registry.env" ] \
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
# shellcheck disable=SC1091
source "$ROOT/registry.env"
: "${REGISTRY:?registry.env must set REGISTRY}"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
VERSION="$(<"$ROOT/build-image/VERSION")"
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
docker image inspect "$REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|| fail "$REGISTRY_IMAGE:$VERSION not found locally — run ./build-image.sh first"
echo "== Logging in to $REGISTRY =="
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest =="
docker push "$REGISTRY_IMAGE:$VERSION"
docker push "$REGISTRY_IMAGE:latest"
echo "== Done =="
echo "Image: $REGISTRY_IMAGE:$VERSION"
File diff suppressed because one or more lines are too long
+59
View File
@@ -0,0 +1,59 @@
# Deck in a Dash - Pebble watch app
The actual watchapp: shows the same daily tarot + astrology reading as
`dist/run-interpreter.sh`, computed entirely on-device (no companion
app, no server - birth data, language, and notification settings are
entered via a Pebble config page and persisted on-watch).
## Layout
```
watch/
package.json Pebble app manifest (uuid, targetPlatforms, messageKeys, resources)
wscript waf build rules - see its own top-of-file comment for
why this app's C sources are NOT a plain src/c/**/*.c
glob (most of the actual logic lives in ../engine/src/,
shared byte-for-byte with the desktop deck-engine/
interpreter-cli binaries)
src/c/ Watch-only C: UI, persisted config, on-device reading
orchestration, daily notification wakeup
src/pkjs/ pkjs config webview (birth data/language/notification
settings) - a self-contained `data:` URI form, no
server
resources/ Bitmap resources (card art, app icon) - resources/img/
is generated (gitignored), see below
scripts/ Build-time codegen: gen_i18n_tables.py (translation
tables from ../engine/i18n/*.lang) and
gen_card_images.py (downsized card art from
../res/img/*.jpeg) - both run automatically by
wscript on every `pebble build` (see its own comment
for why that stays cheap on repeat builds)
```
## Building
Requires the Pebble SDK/tool (`pebble` CLI) and Pillow (`pip install
Pillow`, used only by gen_card_images.py) - neither is part of this
repo's own `make`/`make test` toolchain, which only builds the desktop
`deck-engine`/`interpreter-cli` binaries. From this directory:
```bash
pebble build # -> build/watch.pbw
```
The root Makefile's `make watchapp` target wraps this and copies the
resulting `.pbw` into `dist/`, versioned the same way as `make package` -
see the root CLAUDE.md's "Packaging" section.
## Platform support
`targetPlatforms` deliberately excludes **aplite** (original Pebble /
Pebble Steel). A Pebble app's entire code+data+bss footprint shares one
64KB region on basalt-class platforms, but only 24KB on aplite - and
even this project's minimal skeleton (blank window + one
`reading_generate()` call, no UI/config/persistence/notification code
yet) already uses 19KB of it, leaving only ~5.5KB of headroom. That's
not enough room for the multi-screen UI, on-watch config persistence,
and wakeup-based daily notification this app still needs to add -
basalt and newer (chalk/diorite/emery/flint/gabbro) all have comfortable
46-112KB of free heap for the same skeleton.
+165
View File
@@ -0,0 +1,165 @@
{
"name": "deck_in_a_dash_watch",
"author": "Matthias Ladkau",
"version": "1.0.0",
"keywords": [
"pebble-app"
],
"private": true,
"dependencies": {},
"pebble": {
"displayName": "Deck in a Dash",
"uuid": "7f47ed93-f44e-4c1a-9998-7edcfa61218e",
"sdkVersion": "3",
"enableMultiJS": true,
"capabilities": [
"configurable"
],
"targetPlatforms": [
"basalt",
"chalk",
"diorite",
"emery",
"flint",
"gabbro"
],
"watchapp": {
"watchface": false
},
"messageKeys": [
"BIRTH_YEAR",
"BIRTH_MONTH",
"BIRTH_DAY",
"BIRTH_HOUR",
"BIRTH_MINUTE",
"BIRTH_UTC_OFFSET_MINUTES",
"BIRTH_LAT_MICRODEG",
"BIRTH_LON_MICRODEG",
"GENDER",
"LANG",
"NOTIFY_ENABLED",
"NOTIFY_HOUR",
"NOTIFY_MINUTE"
],
"resources": {
"media": [
{
"type": "bitmap",
"name": "IMAGE_APP_ICON",
"file": "app_icon.png",
"menuIcon": true
},
{
"type": "bitmap",
"name": "IMG_CARD_FOOL",
"file": "img/fool.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_MAGICIAN",
"file": "img/magician.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HIGH_PRIESTESS",
"file": "img/high_priestess.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_EMPRESS",
"file": "img/empress.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_EMPEROR",
"file": "img/emperor.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HIEROPHANT",
"file": "img/hierophant.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_LOVERS",
"file": "img/lovers.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_CHARIOT",
"file": "img/chariot.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_STRENGTH",
"file": "img/strength.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HERMIT",
"file": "img/hermit.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_WHEEL_OF_FORTUNE",
"file": "img/wheel_of_fortune.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_JUSTICE",
"file": "img/justice.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HANGED_MAN",
"file": "img/hanged_man.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_DEATH",
"file": "img/death.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_TEMPERANCE",
"file": "img/temperance.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_DEVIL",
"file": "img/devil.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_TOWER",
"file": "img/tower.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_STAR",
"file": "img/star.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_MOON",
"file": "img/moon.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_SUN",
"file": "img/sun.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_JUDGEMENT",
"file": "img/judgement.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_WORLD",
"file": "img/world.png"
}
]
}
}
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Downsizes res/app_icon_50x50_64_color.png into watch/resources/app_icon.png.
Pebble's own build tooling (process_sdk_resources.py) hard-fails any
"menuIcon": true resource larger than 25x25px - the fixed size Pebble OS
renders app icons at in the watch's own app launcher list (and, synced
from the watch, in the mobile app's installed-apps list too). The source
art in res/ is 50x50 (square, so it downsamples to exactly 25x25 with no
letterboxing) - the color variant is used, per the same "prefer color"
choice as the rest of this app's card art, over the companion
*_gray.png fallback also present in res/.
Generated output, like i18n_tables.auto.c and resources/img/*.png - not
committed (see .gitignore), regenerate by running this script directly
or via `pebble build` (wired into wscript alongside gen_card_images.py).
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_PATH = os.path.normpath(
os.path.join(SCRIPT_DIR, "..", "..", "res", "app_icon_50x50_64_color.png")
)
OUT_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "resources", "app_icon.png"))
MAX_ICON_SIZE = 25 # Pebble's own hard cap for "menuIcon": true resources.
def main():
if os.path.exists(OUT_PATH) and os.path.getmtime(OUT_PATH) >= os.path.getmtime(SRC_PATH):
print(f"{OUT_PATH} already up to date", file=sys.stderr)
return
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
with Image.open(SRC_PATH) as img:
ratio = MAX_ICON_SIZE / max(img.width, img.height)
size = (round(img.width * ratio), round(img.height * ratio))
resized = img.convert("RGBA").resize(size, Image.LANCZOS)
resized.save(OUT_PATH, "PNG", optimize=True)
print(f"wrote {OUT_PATH} ({size[0]}x{size[1]})", file=sys.stderr)
if __name__ == "__main__":
main()
+90
View File
@@ -0,0 +1,90 @@
#!/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. 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) is a hardware-verified ceiling, not a design
choice: full-display-width images (144-260px depending on platform) were
tried first and reliably crash real hardware with "PNG memory allocation
failed" - a runtime PNG-decoder memory constraint independent of the
app's own heap budget (lazy-loading only one image at a time didn't help
either). 72px is the size the original per-card screens used
successfully all session before that experiment.
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()
+85
View File
@@ -0,0 +1,85 @@
#include "app_message.h"
#include <pebble.h>
/* AppMessage's Dictionary has no float tuple type, so latitude/
* longitude/utc_offset_hours (all doubles in BirthData) cross the wire
* as scaled int32s - microdegrees for lat/lon (~0.11m precision, far
* finer than this app's astrology needs), minutes for the UTC offset
* (covers every real-world offset, including the half/quarter-hour
* ones) - see config.c's own persisted representation, which uses the
* same scaling so no conversion happens twice. */
static ConfigUpdatedCallback s_callback;
static bool read_int(DictionaryIterator *iter, uint32_t key, int32_t *out) {
Tuple *t = dict_find(iter, key);
if (!t) return false;
*out = t->value->int32;
return true;
}
static void inbox_received_handler(DictionaryIterator *iter, void *context) {
WatchConfig cfg;
config_load(&cfg); /* start from existing settings - a field the page
* doesn't send (e.g. it only changed the language)
* keeps its previous value rather than resetting. */
int32_t v;
bool got_birth = false;
if (read_int(iter, MESSAGE_KEY_BIRTH_YEAR, &v)) { cfg.birth.year = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_MONTH, &v)) { cfg.birth.month = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_DAY, &v)) { cfg.birth.day = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_HOUR, &v)) { cfg.birth.hour = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_MINUTE, &v)) { cfg.birth.minute = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_UTC_OFFSET_MINUTES, &v)) {
cfg.birth.utc_offset_hours = v / 60.0;
got_birth = true;
}
if (read_int(iter, MESSAGE_KEY_BIRTH_LAT_MICRODEG, &v)) {
cfg.birth.latitude = v / 1000000.0;
got_birth = true;
}
if (read_int(iter, MESSAGE_KEY_BIRTH_LON_MICRODEG, &v)) {
cfg.birth.longitude = v / 1000000.0;
got_birth = true;
}
if (read_int(iter, MESSAGE_KEY_GENDER, &v)) cfg.gender = (Gender)v;
Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG);
if (lang_tuple) {
strncpy(cfg.lang, lang_tuple->value->cstring, sizeof(cfg.lang) - 1);
cfg.lang[sizeof(cfg.lang) - 1] = '\0';
}
if (read_int(iter, MESSAGE_KEY_NOTIFY_ENABLED, &v)) cfg.notify_enabled = (v != 0);
if (read_int(iter, MESSAGE_KEY_NOTIFY_HOUR, &v)) cfg.notify_hour = v;
if (read_int(iter, MESSAGE_KEY_NOTIFY_MINUTE, &v)) cfg.notify_minute = v;
/* Birth data is the one thing that makes a config submission "complete" -
* language/notification-only re-submissions (e.g. from a later re-open
* of the settings page) must not un-configure an already-set-up watch. */
if (got_birth) cfg.configured = true;
config_log(&cfg); /* confirms what was actually received/parsed */
config_save(&cfg);
if (s_callback) s_callback(&cfg);
}
static void inbox_dropped_handler(AppMessageResult reason, void *context) {
APP_LOG(APP_LOG_LEVEL_ERROR, "AppMessage dropped: reason %d", (int)reason);
}
/* app_message_*_size_maximum() return the largest buffer the platform
* *could* hand out (8200 bytes each) - not a size to actually request.
* The real payload is ~150-200 bytes; 256 bytes each leaves headroom
* without eating into this app's ~12KB heap budget (see watch/README.md). */
#define INBOX_SIZE 256
#define OUTBOX_SIZE 256
void app_message_init(ConfigUpdatedCallback on_config_updated) {
s_callback = on_config_updated;
app_message_register_inbox_received(inbox_received_handler);
app_message_register_inbox_dropped(inbox_dropped_handler);
app_message_open(INBOX_SIZE, OUTBOX_SIZE);
}
+20
View File
@@ -0,0 +1,20 @@
#ifndef WATCH_APP_MESSAGE_H
#define WATCH_APP_MESSAGE_H
#include "config.h"
/* Invoked with the freshly-saved config whenever a settings submission
* arrives from the config page (src/pkjs/index.js, task #43) - main.c
* uses this to recompute the reading and refresh/show the menu. */
typedef void (*ConfigUpdatedCallback)(const WatchConfig *cfg);
/* Registers the AppMessage inbox handler and opens the message buffers.
* Call once at startup, before app_event_loop(). Message keys
* (BIRTH_YEAR, BIRTH_UTC_OFFSET_MINUTES, LANG, NOTIFY_ENABLED, etc.) are
* declared in watch/package.json's "messageKeys" and turn into
* MESSAGE_KEY_* constants in the auto-generated message_keys.auto.h -
* see app_message.c's own comment on why latitude/longitude/utc offset
* travel as scaled integers rather than floats. */
void app_message_init(ConfigUpdatedCallback on_config_updated);
#endif
+90
View File
@@ -0,0 +1,90 @@
#include "config.h"
#include <string.h>
/* Persist keys. BirthData's latitude/longitude/utc_offset_hours are
* doubles, but Pebble's persist API has no float storage - stored as
* scaled int32s instead (microdegrees / minutes), matching how
* AppMessage carries the same fields from the config page - see
* app_message.c. */
enum {
PKEY_CONFIGURED = 0,
PKEY_BIRTH_YEAR,
PKEY_BIRTH_MONTH,
PKEY_BIRTH_DAY,
PKEY_BIRTH_HOUR,
PKEY_BIRTH_MINUTE,
PKEY_BIRTH_UTC_OFFSET_MINUTES,
PKEY_BIRTH_LAT_MICRODEG,
PKEY_BIRTH_LON_MICRODEG,
PKEY_GENDER,
PKEY_LANG,
PKEY_NOTIFY_ENABLED,
PKEY_NOTIFY_HOUR,
PKEY_NOTIFY_MINUTE,
};
void config_load(WatchConfig *out) {
memset(out, 0, sizeof *out);
out->configured = persist_exists(PKEY_CONFIGURED) && persist_read_bool(PKEY_CONFIGURED);
if (!out->configured) {
strcpy(out->lang, "en");
return;
}
out->birth.year = (int)persist_read_int(PKEY_BIRTH_YEAR);
out->birth.month = (int)persist_read_int(PKEY_BIRTH_MONTH);
out->birth.day = (int)persist_read_int(PKEY_BIRTH_DAY);
out->birth.hour = (int)persist_read_int(PKEY_BIRTH_HOUR);
out->birth.minute = (int)persist_read_int(PKEY_BIRTH_MINUTE);
out->birth.utc_offset_hours = (double)(int32_t)persist_read_int(PKEY_BIRTH_UTC_OFFSET_MINUTES) / 60.0;
out->birth.latitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LAT_MICRODEG) / 1000000.0;
out->birth.longitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LON_MICRODEG) / 1000000.0;
out->gender = (Gender)persist_read_int(PKEY_GENDER); /* defaults to 0 = GENDER_UNSPECIFIED */
if (persist_read_string(PKEY_LANG, out->lang, sizeof out->lang) <= 0) {
strcpy(out->lang, "en");
}
out->notify_enabled = persist_read_bool(PKEY_NOTIFY_ENABLED);
out->notify_hour = (int)persist_read_int(PKEY_NOTIFY_HOUR);
out->notify_minute = (int)persist_read_int(PKEY_NOTIFY_MINUTE);
}
void config_save(const WatchConfig *cfg) {
persist_write_bool(PKEY_CONFIGURED, cfg->configured);
persist_write_int(PKEY_BIRTH_YEAR, cfg->birth.year);
persist_write_int(PKEY_BIRTH_MONTH, cfg->birth.month);
persist_write_int(PKEY_BIRTH_DAY, cfg->birth.day);
persist_write_int(PKEY_BIRTH_HOUR, cfg->birth.hour);
persist_write_int(PKEY_BIRTH_MINUTE, cfg->birth.minute);
persist_write_int(PKEY_BIRTH_UTC_OFFSET_MINUTES, (int32_t)(cfg->birth.utc_offset_hours * 60.0));
persist_write_int(PKEY_BIRTH_LAT_MICRODEG, (int32_t)(cfg->birth.latitude * 1000000.0));
persist_write_int(PKEY_BIRTH_LON_MICRODEG, (int32_t)(cfg->birth.longitude * 1000000.0));
persist_write_int(PKEY_GENDER, (int32_t)cfg->gender);
persist_write_string(PKEY_LANG, cfg->lang);
persist_write_bool(PKEY_NOTIFY_ENABLED, cfg->notify_enabled);
persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour);
persist_write_int(PKEY_NOTIFY_MINUTE, cfg->notify_minute);
}
/* Cast to int/long rather than %f - Pebble's APP_LOG doesn't support
* floating-point format specifiers. */
void config_log(const WatchConfig *cfg) {
if (!cfg->configured) {
APP_LOG(APP_LOG_LEVEL_INFO, "config: not configured yet");
return;
}
APP_LOG(APP_LOG_LEVEL_INFO, "config: birth %04d-%02d-%02d %02d:%02d UTC offset %dmin",
cfg->birth.year, cfg->birth.month, cfg->birth.day, cfg->birth.hour, cfg->birth.minute,
(int)(cfg->birth.utc_offset_hours * 60));
APP_LOG(APP_LOG_LEVEL_INFO, "config: lat=%ld/1e6 lon=%ld/1e6 gender=%d lang=%s",
(long)(cfg->birth.latitude * 1000000), (long)(cfg->birth.longitude * 1000000),
(int)cfg->gender, cfg->lang);
APP_LOG(APP_LOG_LEVEL_INFO, "config: notify_enabled=%d at %02d:%02d",
cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute);
}
+41
View File
@@ -0,0 +1,41 @@
#ifndef WATCH_CONFIG_H
#define WATCH_CONFIG_H
#include <pebble.h>
#include "../../../engine/src/astro.h"
#include "../../../engine/src/tarot.h" /* Gender */
/* Everything the watch needs that isn't computed fresh each day: birth
* data (the same fields as dist/user.properties), display language, and
* daily notification settings. Persisted on-watch via Pebble's own
* key-value persist API (see config.c) - no companion app, no server,
* matching the rest of this project's architecture. */
typedef struct {
bool configured; /* false until the config page has been submitted once */
BirthData birth;
Gender gender; /* who the reading is for - only affects Celtic Cross
* position text pronouns, see engine/src/tarot.h.
* Defaults to GENDER_UNSPECIFIED (0). */
char lang[8]; /* e.g. "en", "de" - matches engine/i18n/<lang>.lang's own code */
bool notify_enabled;
int notify_hour; /* 0-23, the watch's own local wall-clock time (i.e.
* localtime(), not birth.utc_offset_hours - the
* user's current timezone, synced from their phone,
* not necessarily their birth location's). */
int notify_minute; /* 0-59 */
} WatchConfig;
/* Fills `out` with the persisted config, or a safe all-zero/`configured
* = false` default if nothing has been saved yet (first run). */
void config_load(WatchConfig *out);
void config_save(const WatchConfig *cfg);
/* Prints `cfg` via APP_LOG - the same fields a reading is computed from,
* so callers can confirm on-device what values a shown reading is
* actually based on (there's no way to read them back from the watch
* otherwise). Prints "not configured yet" instead if !cfg->configured. */
void config_log(const WatchConfig *cfg);
#endif
+16
View File
@@ -0,0 +1,16 @@
#ifndef WATCH_I18N_TABLES_H
#define WATCH_I18N_TABLES_H
/* Points i18n_get() (engine/src/i18n.h) at the compiled-in translation
* table for `lang_code`, generated at build time from engine/i18n's
* .lang files by watch/scripts/gen_i18n_tables.py into
* i18n_tables.auto.c - see that script's own doc comment for the format
* and why English is deliberately not one of the compiled tables.
* Passing "en", NULL, or any code with no matching .lang file resets to
* no table at all, so every i18n_get() call falls through to its
* built-in English fallback text - the correct behavior when a user
* switches the watch's language setting back to English at runtime,
* not just on first load. */
void watch_i18n_load(const char *lang_code);
#endif
+75
View File
@@ -0,0 +1,75 @@
#include <pebble.h>
#include "../../../engine/src/i18n.h"
#include "app_message.h"
#include "config.h"
#include "i18n_tables.h"
#include "notify.h"
#include "reading_state.h"
#include "ui_report_window.h"
#include "ui_text_window.h"
static WatchConfig s_config;
static void show_setup_required(void) {
text_window_push(i18n_get("ui.setup_required_title", "Setup Required"),
i18n_get("ui.setup_required_body",
"Open this app's settings from the Pebble mobile app to add your "
"birth details, then reopen Deck in a Dash."));
}
static void on_config_updated(const WatchConfig *cfg) {
bool was_configured = s_config.configured;
s_config = *cfg;
watch_i18n_load(s_config.lang);
if (!s_config.configured) return;
config_log(&s_config); /* the values this newly-recomputed reading is based on */
reading_state_recompute(&s_config.birth, s_config.gender);
notify_reschedule(&s_config); /* settings may have changed notify_enabled/hour/minute */
if (was_configured) {
report_window_reload();
} else {
report_window_push();
}
}
/* Fires only if the scheduled wakeup goes off while this app is already
* open (notify_was_wakeup_launch() below covers the far more common
* case: the wakeup launching the app fresh). Refreshes the same way a
* new day's config submission would, since the report window is already
* on screen and the reading may now be for a new day. */
static void on_wakeup(WakeupId wakeup_id, int32_t cookie) {
(void)wakeup_id;
(void)cookie;
vibes_double_pulse();
if (!s_config.configured) return;
reading_state_recompute(&s_config.birth, s_config.gender);
report_window_reload();
notify_reschedule(&s_config); /* wakeup_schedule() is one-shot - re-arm for tomorrow */
}
int main(void) {
config_load(&s_config);
watch_i18n_load(s_config.lang);
app_message_init(on_config_updated);
notify_subscribe(on_wakeup);
config_log(&s_config); /* the values this session's reading (if any) is based on */
if (s_config.configured) {
reading_state_recompute(&s_config.birth, s_config.gender);
report_window_push();
notify_reschedule(&s_config); /* wakeup_schedule() is one-shot - re-arm on every launch */
if (notify_was_wakeup_launch()) {
vibes_double_pulse();
}
} else {
show_setup_required();
}
app_event_loop();
report_window_deinit();
}
+33
View File
@@ -0,0 +1,33 @@
#include "notify.h"
/* Only one kind of wakeup event exists in this app, so the cookie value
* itself carries no information - it exists only because
* wakeup_schedule()/wakeup_get_launch_event() require one. */
#define NOTIFY_WAKEUP_COOKIE 0
void notify_reschedule(const WatchConfig *cfg) {
wakeup_cancel_all();
if (!cfg->notify_enabled) return;
time_t now = time(NULL);
struct tm target_tm = *localtime(&now);
target_tm.tm_hour = cfg->notify_hour;
target_tm.tm_min = cfg->notify_minute;
target_tm.tm_sec = 0;
time_t target = mktime(&target_tm);
if (target <= now) target += SECONDS_PER_DAY; /* today's time already passed */
WakeupId id = wakeup_schedule(target, NOTIFY_WAKEUP_COOKIE, true);
if (id < 0) {
APP_LOG(APP_LOG_LEVEL_ERROR, "notify: wakeup_schedule failed: %d", (int)id);
}
}
bool notify_was_wakeup_launch(void) {
return launch_reason() == APP_LAUNCH_WAKEUP;
}
void notify_subscribe(WakeupHandler handler) {
wakeup_service_subscribe(handler);
}
+37
View File
@@ -0,0 +1,37 @@
#ifndef WATCH_NOTIFY_H
#define WATCH_NOTIFY_H
#include "config.h"
/* (Re)schedules the daily notification wakeup from `cfg`'s notify_*
* fields: cancels any wakeup this app previously scheduled, then - if
* cfg->notify_enabled - schedules a new one-shot wakeup_schedule() for
* the next occurrence of notify_hour:notify_minute in the watch's own
* local time (today if that time hasn't passed yet, tomorrow otherwise).
* If !cfg->notify_enabled, this only cancels - no new wakeup is
* scheduled.
*
* Safe to call any time, and needs to be: once per app launch (Pebble's
* wakeup_schedule() is one-shot - each firing consumes itself, so the
* next day's has to be scheduled again after it fires or after a wakeup
* that arrives while the app happens to already be open - see
* notify_subscribe()) and on every config submission (settings may have
* changed notify_enabled/notify_hour/notify_minute). */
void notify_reschedule(const WatchConfig *cfg);
/* True if this app launch was triggered by the daily notification wakeup
* firing rather than the user/phone opening it normally - main.c uses
* this to vibrate on launch, since the report window it pushes either
* way already leads with day significance and the top transit (the
* "quick digest" this notification promises), so no separate screen is
* needed for it. */
bool notify_was_wakeup_launch(void);
/* Subscribes `handler` to fire if the scheduled wakeup goes off while
* this app is already running in the foreground - the only case
* notify_was_wakeup_launch() can't see, since no fresh launch happens
* then (see wakeup_service_subscribe()'s own doc comment in pebble.h).
* Call once, early in main(). */
void notify_subscribe(WakeupHandler handler);
#endif
+27
View File
@@ -0,0 +1,27 @@
#include "reading_state.h"
#include <stdio.h>
static DailyReading s_reading;
static DailyInterpretation s_interp;
void reading_state_recompute(const BirthData *birth, Gender gender) {
time_t now = time(NULL);
/* Plain gmtime() (not the POSIX-only gmtime_r()), used immediately -
* same justification as astro.c's own time_from_unix(). */
struct tm *utc = gmtime(&now);
char seed[16];
snprintf(seed, sizeof seed, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
reading_generate(seed, birth, gender, now, &s_reading);
interpret_daily_reading(&s_reading, &s_interp);
}
const DailyReading *reading_state_get_reading(void) {
return &s_reading;
}
const DailyInterpretation *reading_state_get_interpretation(void) {
return &s_interp;
}
+19
View File
@@ -0,0 +1,19 @@
#ifndef WATCH_READING_STATE_H
#define WATCH_READING_STATE_H
#include "../../../engine/src/reading.h"
#include "../../../interpreter/src/significance.h"
/* Recomputes today's reading (astro + tarot + interpretation) from
* `birth`/`gender`, using the watch's current clock: today's UTC date as
* the tarot seed (same convention as dist/run-engine.sh - see CLAUDE.md's
* "Build & run") and the current moment for the transit snapshot. Safe
* to call again later (e.g. the day rolled over while the app stayed
* open) - overwrites the previous result in place. */
void reading_state_recompute(const BirthData *birth, Gender gender);
/* Valid only after at least one reading_state_recompute() call. */
const DailyReading *reading_state_get_reading(void);
const DailyInterpretation *reading_state_get_interpretation(void);
#endif
+35
View File
@@ -0,0 +1,35 @@
#include "resource_map.h"
#include <pebble.h>
uint32_t card_resource_id(TarotCard card) {
switch (card) {
case CARD_FOOL: return RESOURCE_ID_IMG_CARD_FOOL;
case CARD_MAGICIAN: return RESOURCE_ID_IMG_CARD_MAGICIAN;
case CARD_HIGH_PRIESTESS: return RESOURCE_ID_IMG_CARD_HIGH_PRIESTESS;
case CARD_EMPRESS: return RESOURCE_ID_IMG_CARD_EMPRESS;
case CARD_EMPEROR: return RESOURCE_ID_IMG_CARD_EMPEROR;
case CARD_HIEROPHANT: return RESOURCE_ID_IMG_CARD_HIEROPHANT;
case CARD_LOVERS: return RESOURCE_ID_IMG_CARD_LOVERS;
case CARD_CHARIOT: return RESOURCE_ID_IMG_CARD_CHARIOT;
case CARD_STRENGTH: return RESOURCE_ID_IMG_CARD_STRENGTH;
case CARD_HERMIT: return RESOURCE_ID_IMG_CARD_HERMIT;
case CARD_WHEEL_OF_FORTUNE: return RESOURCE_ID_IMG_CARD_WHEEL_OF_FORTUNE;
case CARD_JUSTICE: return RESOURCE_ID_IMG_CARD_JUSTICE;
case CARD_HANGED_MAN: return RESOURCE_ID_IMG_CARD_HANGED_MAN;
case CARD_DEATH: return RESOURCE_ID_IMG_CARD_DEATH;
case CARD_TEMPERANCE: return RESOURCE_ID_IMG_CARD_TEMPERANCE;
case CARD_DEVIL: return RESOURCE_ID_IMG_CARD_DEVIL;
case CARD_TOWER: return RESOURCE_ID_IMG_CARD_TOWER;
case CARD_STAR: return RESOURCE_ID_IMG_CARD_STAR;
case CARD_MOON: return RESOURCE_ID_IMG_CARD_MOON;
case CARD_SUN: return RESOURCE_ID_IMG_CARD_SUN;
case CARD_JUDGEMENT: return RESOURCE_ID_IMG_CARD_JUDGEMENT;
case CARD_WORLD: return RESOURCE_ID_IMG_CARD_WORLD;
}
return RESOURCE_ID_IMG_CARD_FOOL; /* unreachable for a valid TarotCard */
}
int16_t card_image_height_for_width(int16_t width) {
return (int16_t)((width * 1810 + 543) / 1086); /* +543 = round, not truncate */
}
+24
View File
@@ -0,0 +1,24 @@
#ifndef WATCH_RESOURCE_MAP_H
#define WATCH_RESOURCE_MAP_H
#include <stdint.h>
#include "../../../engine/src/tarot.h"
/* Maps a TarotCard to its compiled-in bitmap resource ID. Each of
* watch/package.json's "resources.media" entries is named
* IMG_CARD_<SLUG UPPERCASE>, matching engine/src/tarot_data.c's own
* k_card_slug table (see watch/scripts/gen_card_images.py, which
* generates the underlying PNGs with the same slugs) - Pebble's build
* turns each into a RESOURCE_ID_IMG_CARD_<SLUG> macro in the
* auto-generated resource_ids.auto.h, which this function is the one
* place in the app that switches over. */
uint32_t card_resource_id(TarotCard card);
/* Height in pixels of a card image resized to `width` wide, preserving
* the source art's fixed 1086x1810 aspect ratio - matches
* gen_card_images.py's own resize math, so this stays correct for
* whichever per-platform width variant package.json resolves to. */
int16_t card_image_height_for_width(int16_t width);
#endif
+331
View File
@@ -0,0 +1,331 @@
#include "ui_report_window.h"
#include <pebble.h>
#include "../../../engine/src/i18n.h"
#include "../../../interpreter/src/guidance.h"
#include "../../../interpreter/src/narrative.h"
#include "reading_state.h"
#include "resource_map.h"
#define MARGIN 6
/* Matches gen_card_images.py's own CARD_WIDTH - a hardware-verified
* ceiling, not a design choice: full-display-width images reliably
* crashed the real PNG decoder ("PNG memory allocation failed", a
* firmware-level constraint independent of this app's own heap budget,
* confirmed unaffected by lazy-loading only one image at a time). */
#define IMAGE_WIDTH 72
/* fonts_get_system_font() needs Pebble's runtime initialized, so these
* are resolved once in window_load() rather than at static-init time. */
static GFont s_font_section;
static GFont s_font_subhead;
static GFont s_font_body;
/* Static buffers for every dynamically-built string this window shows -
* tarot_position_description()/tarot_card_meaning()/i18n_get() fallback
* text all point straight at flash/ROM data (see i18n.h's own comment on
* i18n_load_table()), so only the pieces actually assembled at runtime
* (via snprintf/narrative_write/guidance_write) need their own storage.
* Static rather than malloc'd - fixed, known-in-advance sizes, and it
* keeps report_window_reload() from having to reason about freeing the
* previous run's buffers before reusing them. */
static char s_reading_date[48];
static char s_day_title[64];
static char s_transit_titles[MAX_SIGNIFICANT_ITEMS][96];
static char s_narratives[MAX_SIGNIFICANT_ITEMS][NARRATIVE_TEXT_MAX];
static char s_position_titles[TAROT_SPREAD_SIZE][96];
static char s_guidance[GUIDANCE_TEXT_MAX];
/* Loads/frees each position's GBitmap as scroll_offset_changed() sees it
* enter/leave the visible viewport, rather than decoding all ten Celtic
* Cross images up front - a defensive habit left over from an earlier,
* larger IMAGE_WIDTH (see that constant's own comment); at 72px this
* struct's images are small enough that it's no longer strictly
* necessary, but it's harmless to keep. */
typedef struct {
TarotCard card;
int16_t y;
int16_t height;
BitmapLayer *bitmap_layer;
GBitmap *bitmap;
} ImageSlot;
static ImageSlot s_slots[TAROT_SPREAD_SIZE];
/* Attitude (0) and Outcome (1) preview slots, drawn at half screen
* width each so the two sit side by side - the BitmapLayer's own bounds
* (narrower than IMAGE_WIDTH on 144px-wide platforms) clip the centered
* image automatically, same as GAlignCenter already does for the
* full-width slots above, no separate scaling logic needed. */
#define REPEAT_SLOT_COUNT 2
static ImageSlot s_repeat_slots[REPEAT_SLOT_COUNT];
#define MAX_TEXT_LAYERS 50
static TextLayer *s_text_layers[MAX_TEXT_LAYERS];
static int s_text_layer_count;
static Window *s_window;
static ScrollLayer *s_scroll_layer;
static int16_t s_screen_height;
static const char *day_level_name(DaySignificance level) {
switch (level) {
case DAY_QUIET: return i18n_get("interp.day_level.quiet", "Quiet");
case DAY_NOTABLE: return i18n_get("interp.day_level.notable", "Notable");
case DAY_SIGNIFICANT: return i18n_get("interp.day_level.significant", "Significant");
case DAY_MAJOR: return i18n_get("interp.day_level.major", "Major");
default: return "";
}
}
static const char *reversed_marker(bool reversed) {
return reversed ? i18n_get("ui.reversed", "(Reversed)") : "";
}
static int16_t add_text_at(int16_t x, int16_t width, int16_t y, const char *text, GFont font) {
TextLayer *tl = text_layer_create(GRect(x, y, width, 4000));
text_layer_set_font(tl, font);
text_layer_set_text(tl, text);
text_layer_set_overflow_mode(tl, GTextOverflowModeWordWrap);
GSize size = text_layer_get_content_size(tl);
size.w = width;
size.h += 4;
text_layer_set_size(tl, size);
scroll_layer_add_child(s_scroll_layer, text_layer_get_layer(tl));
s_text_layers[s_text_layer_count++] = tl;
return y + size.h + MARGIN;
}
static int16_t add_text(int16_t width, int16_t y, const char *text, GFont font) {
return add_text_at(0, width, y, text, font);
}
static void teardown_content(void) {
for (int i = 0; i < s_text_layer_count; i++) {
text_layer_destroy(s_text_layers[i]);
}
s_text_layer_count = 0;
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
if (s_slots[i].bitmap) {
gbitmap_destroy(s_slots[i].bitmap);
s_slots[i].bitmap = NULL;
}
if (s_slots[i].bitmap_layer) {
bitmap_layer_destroy(s_slots[i].bitmap_layer);
s_slots[i].bitmap_layer = NULL;
}
}
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
if (s_repeat_slots[i].bitmap) {
gbitmap_destroy(s_repeat_slots[i].bitmap);
s_repeat_slots[i].bitmap = NULL;
}
if (s_repeat_slots[i].bitmap_layer) {
bitmap_layer_destroy(s_repeat_slots[i].bitmap_layer);
s_repeat_slots[i].bitmap_layer = NULL;
}
}
}
static void update_slot_visibility(ImageSlot *slot, int16_t visible_top, int16_t visible_bottom) {
bool visible = slot->y < visible_bottom && slot->y + slot->height > visible_top;
if (visible && !slot->bitmap) {
slot->bitmap = gbitmap_create_with_resource(card_resource_id(slot->card));
bitmap_layer_set_bitmap(slot->bitmap_layer, slot->bitmap);
} else if (!visible && slot->bitmap) {
bitmap_layer_set_bitmap(slot->bitmap_layer, NULL);
gbitmap_destroy(slot->bitmap);
slot->bitmap = NULL;
}
}
static void update_visible_images(void) {
GPoint offset = scroll_layer_get_content_offset(s_scroll_layer);
int16_t visible_top = -offset.y;
int16_t visible_bottom = visible_top + s_screen_height;
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
update_slot_visibility(&s_slots[i], visible_top, visible_bottom);
}
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
update_slot_visibility(&s_repeat_slots[i], visible_top, visible_bottom);
}
}
static void scroll_offset_changed(ScrollLayer *scroll_layer, void *context) {
update_visible_images();
}
static void build_content(int16_t width) {
const DailyReading *reading = reading_state_get_reading();
const DailyInterpretation *interp = reading_state_get_interpretation();
int16_t y = MARGIN;
/* The day this reading is for (reading->utc_moment, set by
* reading_generate() - see reading_state_recompute()), not necessarily
* "today" if the report is viewed right at a UTC-midnight boundary
* before the next recompute. Plain gmtime(), same justification as
* reading_state.c's own use of it. */
struct tm *utc = gmtime(&reading->utc_moment);
snprintf(s_reading_date, sizeof s_reading_date, "%s %04d-%02d-%02d",
i18n_get("interp.reading_date", "Reading for:"), utc->tm_year + 1900,
utc->tm_mon + 1, utc->tm_mday);
y = add_text(width, y, s_reading_date, s_font_body);
y = add_text(width, y, i18n_get("interp.heading_day_significance", "Day Significance"), s_font_section);
snprintf(s_day_title, sizeof s_day_title, "%s (%d/%d)", day_level_name(interp->day_level),
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
y = add_text(width, y, s_day_title, s_font_body);
/* Build every transit's title + narrative up front (used both for the
* repeated top-item line below and the full list further down), so
* neither buffer is computed twice for item 0. */
if (interp->top_item_count > 0) {
for (int i = 0; i < interp->top_item_count; i++) {
const Aspect *a = &interp->top_items[i].aspect;
snprintf(s_transit_titles[i], sizeof s_transit_titles[i], "%s %s %s %s %s",
i18n_get("ui.transiting", "Transiting"), astro_body_name(a->transiting_planet),
astro_aspect_name(a->type), i18n_get("ui.natal", "natal"),
astro_body_name(a->natal_planet));
narrative_write(s_narratives[i], sizeof s_narratives[i], "", a,
reading->transits.bodies[a->transiting_planet].house);
}
}
y = add_text(width, y, i18n_get("interp.heading_guidance", "Guidance"), s_font_section);
/* Attitude/Outcome preview, side by side, image only - no per-card
* caption text. A "Position: Card Name (Reversed)" label doesn't fit
* a half-width column without wrapping into 3+ short lines, and with
* both columns wrapping independently the reader ends up reading
* fragments across columns rather than down one column at a time
* (confirmed on-device: it reads as one garbled interleaved sentence,
* not two). Captions are unnecessary anyway - the guidance text
* printed immediately below already names and quotes both cards in
* full ("Deine Haltungskarte ist ..." / "Das wahrscheinliche Ergebnis
* ... ist ..."), and both reappear with their full position/name/
* meaning again in the Celtic Cross walkthrough further down. */
{
int16_t half_w = width / 2;
const TarotDraw *attitude = &reading->spread.positions[POSITION_ATTITUDE];
const TarotDraw *outcome = &reading->spread.positions[POSITION_OUTCOME];
int16_t img_height = card_image_height_for_width(IMAGE_WIDTH);
s_repeat_slots[0].card = attitude->card;
s_repeat_slots[0].y = y;
s_repeat_slots[0].height = img_height;
s_repeat_slots[0].bitmap_layer = bitmap_layer_create(GRect(0, y, half_w, img_height));
bitmap_layer_set_alignment(s_repeat_slots[0].bitmap_layer, GAlignCenter);
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[0].bitmap_layer));
s_repeat_slots[1].card = outcome->card;
s_repeat_slots[1].y = y;
s_repeat_slots[1].height = img_height;
s_repeat_slots[1].bitmap_layer =
bitmap_layer_create(GRect(half_w, y, width - half_w, img_height));
bitmap_layer_set_alignment(s_repeat_slots[1].bitmap_layer, GAlignCenter);
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[1].bitmap_layer));
y += img_height + MARGIN;
}
if (interp->top_item_count > 0) {
y = add_text(width, y, s_transit_titles[0], s_font_subhead);
y = add_text(width, y, s_narratives[0], s_font_body);
}
guidance_write(s_guidance, sizeof s_guidance, "", interp, &reading->spread);
y = add_text(width, y, s_guidance, s_font_body);
y = add_text(width, y, i18n_get("interp.heading_transits", "Significant Transits"), s_font_section);
if (interp->top_item_count == 0) {
y = add_text(width, y, i18n_get("interp.no_notable_transits", "No notable transits today."), s_font_body);
} else {
for (int i = 0; i < interp->top_item_count; i++) {
y = add_text(width, y, s_transit_titles[i], s_font_subhead);
y = add_text(width, y, s_narratives[i], s_font_body);
}
}
y = add_text(width, y, i18n_get("ui.celtic_cross", "Celtic Cross"), s_font_section);
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &reading->spread.positions[i];
snprintf(s_position_titles[i], sizeof s_position_titles[i], "%s: %s %s",
tarot_position_name((CelticCrossPosition)i, reading->gender), tarot_card_name(draw->card),
reversed_marker(draw->reversed));
y = add_text(width, y, s_position_titles[i], s_font_subhead);
s_slots[i].card = draw->card;
s_slots[i].y = y;
s_slots[i].height = card_image_height_for_width(IMAGE_WIDTH);
int16_t image_x = (width - IMAGE_WIDTH) / 2;
s_slots[i].bitmap_layer = bitmap_layer_create(GRect(image_x, y, IMAGE_WIDTH, s_slots[i].height));
bitmap_layer_set_alignment(s_slots[i].bitmap_layer, GAlignCenter);
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_slots[i].bitmap_layer));
y += s_slots[i].height + MARGIN;
y = add_text(width, y, tarot_position_description((CelticCrossPosition)i, reading->gender), s_font_body);
y = add_text(width, y, tarot_card_meaning(draw->card, draw->reversed), s_font_body);
}
scroll_layer_set_content_size(s_scroll_layer, GSize(width, y));
}
static void window_load(Window *window) {
s_font_section = fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD);
s_font_subhead = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
s_font_body = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
Layer *window_layer = window_get_root_layer(window);
GRect bounds = layer_get_bounds(window_layer);
s_screen_height = bounds.size.h;
s_scroll_layer = scroll_layer_create(bounds);
scroll_layer_set_click_config_onto_window(s_scroll_layer, window);
scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks){
.content_offset_changed_handler = scroll_offset_changed,
});
layer_add_child(window_layer, scroll_layer_get_layer(s_scroll_layer));
build_content(bounds.size.w);
update_visible_images();
}
static void window_unload(Window *window) {
teardown_content();
scroll_layer_destroy(s_scroll_layer);
s_scroll_layer = NULL;
}
void report_window_push(void) {
if (!s_window) {
s_window = window_create();
window_set_window_handlers(s_window, (WindowHandlers){
.load = window_load,
.unload = window_unload,
});
}
window_stack_push(s_window, true);
}
void report_window_reload(void) {
if (!s_window || !s_scroll_layer) return;
Layer *window_layer = window_get_root_layer(s_window);
int16_t width = layer_get_bounds(window_layer).size.w;
teardown_content();
build_content(width);
update_visible_images();
}
void report_window_deinit(void) {
if (s_window) window_destroy(s_window);
s_window = NULL;
}
+23
View File
@@ -0,0 +1,23 @@
#ifndef WATCH_UI_REPORT_WINDOW_H
#define WATCH_UI_REPORT_WINDOW_H
/* The app's only real screen: one continuously scrollable page mirroring
* the interpreter's own --format text/html report - day significance,
* each significant transit's narrative, the full Celtic Cross spread
* (each position's card art at the watch's own display width, name,
* description, and meaning), and the guidance paragraph last - see
* reading_state.h for where all of this data comes from.
*
* Card art is loaded lazily as it scrolls into view and freed once it
* scrolls back out - a full-display-width card image is too large to
* keep all ten resident at once on this app's RAM budget (see
* ui_report_window.c's own comment) - so unlike a plain ScrollLayer of
* static content, this needs report_window_push()'s window to actually
* be on-screen to drive that loading; call report_window_reload() (not
* push() again) to refresh already-built content after the day rolls
* over or new birth data is submitted. */
void report_window_push(void);
void report_window_reload(void);
void report_window_deinit(void);
#endif
+74
View File
@@ -0,0 +1,74 @@
#include "ui_text_window.h"
#include <pebble.h>
#define TITLE_HEIGHT 30
typedef struct {
char *title;
char *body;
TextLayer *title_layer;
ScrollLayer *scroll_layer;
TextLayer *body_layer;
} TextWindowState;
static void window_load(Window *window) {
TextWindowState *state = window_get_user_data(window);
Layer *window_layer = window_get_root_layer(window);
GRect bounds = layer_get_bounds(window_layer);
state->title_layer = text_layer_create(GRect(0, 0, bounds.size.w, TITLE_HEIGHT));
text_layer_set_font(state->title_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
text_layer_set_text(state->title_layer, state->title);
text_layer_set_overflow_mode(state->title_layer, GTextOverflowModeTrailingEllipsis);
layer_add_child(window_layer, text_layer_get_layer(state->title_layer));
GRect scroll_bounds = GRect(0, TITLE_HEIGHT, bounds.size.w, bounds.size.h - TITLE_HEIGHT);
state->scroll_layer = scroll_layer_create(scroll_bounds);
scroll_layer_set_click_config_onto_window(state->scroll_layer, window);
/* Tall placeholder height so text_layer_get_content_size() below can
* measure the real wrapped height - standard Pebble ScrollLayer
* pattern (a TextLayer taller than its content just clips nothing). */
state->body_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000));
text_layer_set_font(state->body_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
text_layer_set_text(state->body_layer, state->body);
text_layer_set_overflow_mode(state->body_layer, GTextOverflowModeWordWrap);
GSize content_size = text_layer_get_content_size(state->body_layer);
content_size.w = scroll_bounds.size.w;
content_size.h += 4;
text_layer_set_size(state->body_layer, content_size);
scroll_layer_set_content_size(state->scroll_layer, content_size);
scroll_layer_add_child(state->scroll_layer, text_layer_get_layer(state->body_layer));
layer_add_child(window_layer, scroll_layer_get_layer(state->scroll_layer));
}
static void window_unload(Window *window) {
TextWindowState *state = window_get_user_data(window);
text_layer_destroy(state->title_layer);
text_layer_destroy(state->body_layer);
scroll_layer_destroy(state->scroll_layer);
free(state->title);
free(state->body);
free(state);
window_destroy(window);
}
void text_window_push(const char *title, const char *body) {
TextWindowState *state = malloc(sizeof(TextWindowState));
memset(state, 0, sizeof *state);
state->title = malloc(strlen(title) + 1);
strcpy(state->title, title);
state->body = malloc(strlen(body) + 1);
strcpy(state->body, body);
Window *window = window_create();
window_set_user_data(window, state);
window_set_window_handlers(window, (WindowHandlers){
.load = window_load,
.unload = window_unload,
});
window_stack_push(window, true);
}
+16
View File
@@ -0,0 +1,16 @@
#ifndef WATCH_UI_TEXT_WINDOW_H
#define WATCH_UI_TEXT_WINDOW_H
/* Pushes a scrollable full-screen text window: `title` as a bold header,
* `body` below it, word-wrapped and scrollable with the up/down buttons.
* Used for every screen that's just prose (Summary/Guidance, a single
* transit's narrative) - the Celtic Cross card screens use
* ui_card_window.h instead, which also shows the card's image.
*
* Copies both strings internally (into the pushed window's own heap
* state, freed when it's popped), so the caller's buffer doesn't need to
* outlive the call - every caller so far builds `title`/`body` in a
* short-lived local buffer right before pushing. */
void text_window_push(const char *title, const char *body);
#endif
File diff suppressed because one or more lines are too long
+99
View File
@@ -0,0 +1,99 @@
#
# Pebble app build rules for Deck in a Dash.
#
# Unlike a typical `pebble new-project` skeleton, this app's C sources
# aren't all under src/c/ - most of the actual reading logic lives in
# ../engine/src/ (rng, tarot, tarot_data, astro, i18n, reading) and is
# shared byte-for-byte with the desktop deck-engine/interpreter-cli
# binaries (see the root CLAUDE.md's "Portability to the watch"
# section). build() below explicitly lists the engine source files this
# app needs and glues them onto src/c/**/*.c's own sources - it does NOT
# glob ../engine/src/*.c wholesale, because that directory also contains
# reading.c's desktop-only fprintf-based print functions (compiled out
# under #ifndef PBL_SDK_3, so harmless to include) but must never pull in
# engine/third_party/astronomy/ (the vendored high-precision ephemeris,
# ~127KB compiled - alone bigger than a whole Pebble app's 64KB
# code+data+bss budget). This app uses lowprec_ephemeris.c instead - see
# that file's own header comment.
import os.path
import subprocess
import sys
top = '.'
out = 'build'
# Engine source files this app links in, relative to ../engine/src/ -
# see the module comment above for why this is an explicit list rather
# than a glob.
ENGINE_SRCS = [
'rng.c',
'tarot.c',
'tarot_data.c',
'astro.c',
'i18n.c',
'reading.c',
'lowprec_ephemeris.c',
]
# interpreter/'s significance/narrative/guidance scoring and text - see
# CLAUDE.md's "Interpretation" section. Unlike the desktop interpreter-cli
# (which deliberately keeps these header-only against the engine, for
# independent testability without a real ephemeris - see significance.h's
# own comment), the watch links their real .c implementations straight
# into the same binary as the engine above: there's no deck-engine/
# interpreter-cli JSON pipe to speak of on a single device, just one
# process computing and immediately rendering its own reading. json.c/
# reading_io.c (the JSON parser/loader) are never linked - the watch has
# no JSON to parse, it calls reading_generate() directly.
INTERP_SRCS = [
'significance.c',
'narrative.c',
'guidance.c',
]
def options(ctx):
ctx.load('pebble_sdk')
def configure(ctx):
ctx.load('pebble_sdk')
def build(ctx):
ctx.load('pebble_sdk')
# Regenerate src/c/i18n_tables.auto.c from engine/i18n/*.lang,
# resources/img/*.png from res/img/*.jpeg, and resources/app_icon.png
# from res/app_icon_50x50_64_color.png, before compiling/bundling
# anything - see each script's own doc comment. All three run eagerly
# (not as waf Tasks) since the bundle step below depends on their
# output existing; gen_card_images.py/gen_app_icon.py both skip
# regenerating anything already up to date, so this stays cheap on
# repeat builds. Requires Pillow (`pip install Pillow`) - see
# watch/README.md.
subprocess.check_call([sys.executable, 'scripts/gen_i18n_tables.py'])
subprocess.check_call([sys.executable, 'scripts/gen_card_images.py'])
subprocess.check_call([sys.executable, 'scripts/gen_app_icon.py'])
engine_dir = ctx.path.parent.find_dir('engine/src')
engine_nodes = [engine_dir.find_node(name) for name in ENGINE_SRCS]
interp_dir = ctx.path.parent.find_dir('interpreter/src')
interp_nodes = [interp_dir.find_node(name) for name in INTERP_SRCS]
binaries = []
cached_env = ctx.env
for platform in ctx.env.TARGET_PLATFORMS:
ctx.env = ctx.all_envs[platform]
ctx.set_group(ctx.env.PLATFORM_NAME)
app_elf = '{}/pebble-app.elf'.format(ctx.env.BUILD_DIR)
app_sources = ctx.path.ant_glob('src/c/**/*.c') + engine_nodes + interp_nodes
ctx.pbl_build(source=app_sources, target=app_elf, bin_type='app')
binaries.append({'platform': platform, 'app_elf': app_elf})
ctx.env = cached_env
ctx.set_group('bundle')
ctx.pbl_bundle(binaries=binaries,
js=ctx.path.ant_glob(['src/pkjs/**/*.js',
'src/pkjs/**/*.json']),
js_entry_file='src/pkjs/index.js')