Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Cardagain

Spaced repetition flashcards for handheld Linux devices, specifically the Anbernic RG35XXSP running Knulli.

What it is

Cardagain is an Anki-style flashcard app you can study on your handheld. It implements the SM-2 algorithm for scheduling reviews, so cards you struggle with come back sooner, and cards you know well wait longer.

Key features

  • Study anywhere: Review flashcards on your RG35XXSP during commutes, breaks, or couch time
  • SM-2 scheduling: Same spaced repetition algorithm as Anki — efficient, proven
  • CSV deck import: Add decks by dropping .csv files in the data folder
  • No sync needed: All data stays on the device (or SD card)

What it looks like

Main Menu          Deck Select           Study (Front)         Study (Back)
┌──────────────┐   ┌──────────────┐      ┌──────────────┐      ┌──────────────┐
│ Cardagain │   │ Japanese     │      │ QUESTION     │      │ QUESTION     │
│              │   │ 5 due | 3 new│      │              │      │ Hello        │
│ > Study      │   │              │      │ Hello        │      │ ──────────── │
│   Stats      │   │ > French     │      │              │      │ ANSWER       │
│   Quit       │   │ 2 due | 0 new│      │ Press A to   │      │ こんにちは   │
│              │   │              │      │ flip         │      │              │
└──────────────┘   └──────────────┘      └──────────────┘      │ [Again][Good]│
                                                                │ [Hard][Easy] │
                                                                └──────────────┘

Supported devices

DeviceOSStatus
Anbernic RG35XXSPKnulliPrimary target
Anbernic RG35XX HKnulliShould work
Anbernic RG35XX PlusKnulliShould work
Other Knulli/ArkOS devicesAny LinuxUnknown

How it works

  1. Add decks: Drop .csv files into the app’s data directory
  2. Study daily: Open the app, select a deck, review due cards
  3. Rate yourself: Again (1 min), Hard (2-3 days), Good (default interval), Easy (longer)
  4. Come back tomorrow: The SM-2 algorithm schedules your next review

Next steps

Quick Start

Get Cardagain running on your RG35XXSP in a few minutes.

Install on Knulli

  1. Download cardagain-0.1.0.zip from the releases page
  2. Extract it to your SD card’s roms/tools/ directory so you have:
    /userdata/roms/tools/
    └── cardagain/              # app folder
        ├── Cardagain.sh        # launcher script
        ├── cardagain           # binary
        ├── fonts/              # bundled font
        └── gameinfo.xml        # EmulationStation metadata
    
  3. On the device, press STARTGame SettingsUpdate Gameslist
  4. Launch Cardagain from the Tools menu

Option B: PortMaster

  1. Download cardagain-0.1.0.zip from the releases page
  2. Copy it to your device’s roms/ports/ folder
  3. Install via the PortMaster GUI
  4. Launch from the Ports menu

First launch

The app creates a demo Japanese deck automatically. You can study it immediately or add your own decks.

Study your first cards

  1. From the main menu, select Study
  2. Pick the Japanese deck
  3. You’ll see a card front, e.g. “Hello”
  4. Press A to flip and see the answer: “こんにちは”
  5. Rate yourself:
    • A (Again) — You didn’t know it. Review in 1 minute.
    • B (Good) — You got it. Standard interval.
    • X (Hard) — You barely got it. Shorter interval.
    • Y (Easy) — Too easy. Longer interval.
  6. Repeat until the session is complete

Updating

To update to a new version:

  1. Download the new release zip
  2. Extract it over your existing folder (tools/ or ports/)
  3. Your decks and reviews in data.json are preserved
  4. Press STARTGame SettingsUpdate Gameslist

Check your version on the Stats screen.

Add your own deck

Create a .csv file on your computer:

# MyDeck.csv
front,back
Hello,こんにちは
Thank you,ありがとう
Good morning,おはよう

Copy it to the app’s data directory (/userdata/roms/tools/cardagain/ or /roms/ports/cardagain/, where data.json lives), then restart the app. The deck appears automatically.

See Adding Decks for full details.

Controls

RG35XXSP Button Mapping

ButtonAction
D-Pad Up/DownNavigate menus, scroll lists
AConfirm / Flip card / Rate Again
BBack / Rate Good
XRate Hard
YRate Easy
MenuQuit application

Context-specific actions

  • D-Pad: Select between Study / Stats / Quit
  • A: Confirm selection

Deck Select

  • D-Pad: Navigate deck list
  • A: Start studying selected deck
  • B: Back to Main Menu

Study Session (Front of card)

  • A: Flip card to show answer
  • B: Back to deck list (abandon session)

Study Session (Back of card)

  • A: Rate Again (failed, review soon)
  • B: Rate Good (got it, normal interval)
  • X: Rate Hard (barely got it, shorter interval)
  • Y: Rate Easy (too easy, longer interval)

Stats Screen

  • A or B: Back to Main Menu

macOS Development (Keyboard)

When running on macOS for testing:

KeyMaps to
↑ / ↓D-Pad
Space / Enter / ZA button
Backspace / XB button
CX button
VY button
EscapeMenu / Quit

Adding Decks

Cardagain imports decks by scanning its data directory for .csv and .json files on startup.

Where to place files

The data directory is the same folder the binary lives in. On Knulli it’s typically:

/userdata/roms/tools/cardagain/

The simplest way to add a deck. The filename becomes the deck name.

Example: Spanish.csv

# Lines starting with # are comments
Hello,Hola
Thank you,Gracias
Good morning,Buenos días
How are you?,¿Cómo estás?
Good night,Buenas noches

CSV rules

  • Filename = deck name (without .csv)
  • One card per line: front,back
  • Comments: Lines starting with # are ignored
  • Empty lines: Skipped automatically
  • Duplicate names: If a deck with the same name already exists, the file is ignored
  • Malformed lines: Lines without a comma are skipped

JSON format

You can also place individual deck files exported from the app (or hand-written) as .json files. The format matches the internal Deck structure.

How importing works

  1. Start the app
  2. It scans the data directory for .csv and .json files
  3. New decks are imported and immediately saved to data.json
  4. The deck appears in the Deck Select screen

Creating decks on your computer

Use any text editor or spreadsheet app. Save as .csv with UTF-8 encoding (important for non-Latin characters like Japanese or Arabic).

Tips for large decks

  • One file per deck keeps things organized
  • Use comments to group cards by topic
  • Test with a small deck first (10-20 cards)

Example: Multi-topic deck

# Japanese Basics.csv
# Greetings
Hello,こんにちは
Good morning,おはよう
Thank you,ありがとう

# Numbers
One,一
Two,二
Three,三

# Common phrases
Where is the bathroom?,トイレはどこですか
I don't understand,わかりません

Studying

Cardagain uses the SM-2 algorithm (same as Anki) to schedule your reviews. The goal is to review a card right when you’re about to forget it — not too early (wastes time) and not too late (you’ve already forgotten).

Study session flow

  1. Select deck from the Deck Select screen
  2. Study screen shows the front of the first due card
  3. Press A to flip and reveal the answer
  4. Rate yourself with A/B/X/Y
  5. Next card appears automatically
  6. Session ends when all due cards are reviewed

Rating guide

ButtonRatingMeaningWhen to use
AAgainComplete failureYou couldn’t recall the answer at all
XHardDifficult recallYou got it but struggled; felt unsure
BGoodSolid recallYou got it with normal effort (default)
YEasyInstant recallYou knew it immediately; almost too easy

How SM-2 schedules cards

The algorithm tracks each card’s easiness factor (EF), interval, and repetition count:

  • Again: Resets the card to 1-minute review
  • Hard: Increases interval slightly, lowers EF
  • Good: Standard interval increase (EF × current interval)
  • Easy: Larger interval increase, raises EF

New cards start with a 1-day interval, then 6 days if you get them right twice. From there, intervals grow multiplicatively based on your EF.

Session tips

  • Study daily if possible — consistency beats intensity
  • Be honest with ratings — the algorithm only works with accurate data
  • It’s okay to fail — “Again” is the most important button for learning
  • Stop when tired — come back later; fatigue hurts retention

What “due” means

A card is “due” when its scheduled review date has passed. The app shows:

Japanese - 3/15

Meaning you’ve reviewed 3 of 15 due cards in this session.

New cards vs reviews

  • New cards (never studied) are shown first
  • Due reviews are sorted by overdue date
  • Learning cards (recently failed) are mixed in appropriately

Data persistence

All reviews are saved to data.json immediately. If the app crashes or you quit mid-session, your progress up to the last completed card is preserved.

Building for RG35XXSP

You have three options to get a working binary onto your device.

This builds inside a Linux aarch64 container, avoiding cross-compilation issues.

just docker-aarch64
just package           # Create tools/ folder zip (default)
# or:
just package-portmaster  # Create PortMaster zip

Requirements: Docker Desktop installed.

Option B: Native Linux / WSL2

If you have a Linux environment with the aarch64 cross-compiler:

just install-deps-debian      # apt install libsdl2-dev libsdl2-ttf-dev
just install-cross-debian     # apt install gcc-aarch64-linux-gnu
just aarch64                  # Cross-compile
just package                  # Create tools/ folder zip

WSL2 with Debian or Ubuntu is the most reliable native setup.

Option C: Build Directly on the Device

The RG35XXSP runs Linux. If you can SSH into it:

# On the device
sudo apt install libsdl2-dev libsdl2-ttf-dev git curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env

git clone <repo>
cd cardagain
cargo build --release

# Copy binary to the tools directory
mkdir -p /userdata/roms/tools/cardagain/
cp target/release/cardagain /userdata/roms/tools/cardagain/

# Download an open-source font
wget https://github.com/dejavu-fonts/dejavu-fonts/raw/master/ttf/DejaVuSans.ttf
mkdir -p /userdata/roms/tools/cardagain/fonts/
cp DejaVuSans.ttf /userdata/roms/tools/cardagain/fonts/

This avoids ALL cross-compilation issues entirely.

Installing

Tools Folder (Default)

Extract the zip to /userdata/roms/tools/.

Required structure:

/userdata/roms/tools/
└── cardagain/                # app folder
    ├── Cardagain.sh          # launcher (EmulationStation discovers this)
    ├── cardagain             # binary
    ├── fonts/                # bundled font
    └── gameinfo.xml          # metadata
  1. Extract cardagain-0.1.0.zip to /userdata/roms/tools/
  2. Press STARTGame SettingsUpdate Gameslist
  3. Launch Cardagain from the Tools menu

PortMaster

  1. Copy cardagain-0.1.0.zip to roms/ports/
  2. Install via the PortMaster GUI
  3. Launch from the Ports menu

Updating

To update to a new version:

  1. Download the new cardagain-X.Y.Z.zip
  2. Extract it over your existing folder (tools/ or ports/)
  3. Your data.json (decks, reviews) and any .csv decks are preserved — they are not included in the release zip, so extracting won’t overwrite them
  4. Press STARTGame SettingsUpdate Gameslist

Troubleshooting

ProblemLikely causeFix
Not showing in Tools menuMissing Cardagain.sh launcherEnsure Cardagain.sh is inside cardagain/ folder
Crash on launchMissing libSDL2_ttfInstall SDL2_ttf on the device
Black screenWrong working directoryEnsure launcher cd’s to the app folder
No controller inputSDL2 gamecontroller not initializedCheck launch.log in cardagain/ folder
Cards not importingWrong directoryPlace .csv files in cardagain/ where data.json lives

Architecture

Cardagain is a Rust application using SDL2 for rendering and input. It’s designed for simplicity and low resource usage on handheld devices.

Project structure

src/
├── main.rs       # Entry point: SDL2 init, panic hooks, main loop
├── app.rs        # Screen state machine (MainMenu → DeckSelect → Study → Stats)
├── data.rs       # Card, Deck, Review models + SM-2 scheduler
├── import.rs     # CSV/JSON deck import logic
├── input.rs      # Keyboard + gamecontroller input mapping
├── render.rs     # SDL2 canvas, font rendering, UI primitives
└── study.rs      # Study session state machine (Front → Flip → Rate)

fonts/            # Bundled fonts (copied into release zip)
gameinfo.xml      # EmulationStation metadata

scripts/
├── build-aarch64.sh      # Cross-compile script
├── docker-build.sh       # Docker-based aarch64 build
├── Dockerfile.build      # Ubuntu 20.04 + SDL2 build image
└── package-tools.sh      # Create release zip

book.toml             # mdbook configuration
justfile              # Build recipes (native, aarch64, package)

Data flow

Startup
  → Load data.json (or create demo deck)
  → Scan for .csv/.json deck files
  → Import new decks
  → Save data.json

Main loop (60 FPS)
  → Poll input (keyboard + controller)
  → Update app state
  → Render current screen
  → Sleep to cap at ~30 FPS (save battery)

Study session
  → Build due card queue (new first, then by due date)
  → Show front → wait for flip
  → Show back → wait for rating
  → Apply SM-2 schedule
  → Save after each rating

Key design decisions

  • No allocations in hot path: Textures are recreated per frame (simple but not optimal; acceptable for a flashcard app)
  • Immediate-mode UI: No widget tree; draw calls happen directly in draw_* methods
  • Blocking save: data.json is rewritten synchronously after each rating. Cards are small; this is fast enough
  • No threading: Single-threaded; SDL2 event pump drives everything

Screen state machine

[MainMenu] --A(Study)--> [DeckSelect] --A(start session)--> [Study]
                                          B(back)                 |
                                                                   |
[MainMenu] <----------------------B(abandon)----------------------|
      |                                                             |
      |----------------A(Stats)----------------> [Stats] <---------|

Memory usage

Typical footprint on RG35XXSP:

ComponentSize
Binary~500 KB
Heap (fonts + textures)~5-10 MB
Data (1000 cards)~200 KB JSON
Total< 20 MB

Well within the RG35XXSP’s 1GB RAM.

Data Model

Core types

Card

#![allow(unused)]
fn main() {
pub struct Card {
    pub id: u64,
    pub deck_id: u64,
    pub front: String,          // Question
    pub back: String,           // Answer
    pub created_at: DateTime<Utc>,
    pub sm2: Sm2State,
}
}

Sm2State

#![allow(unused)]
fn main() {
pub struct Sm2State {
    pub easiness_factor: f64,   // Starts at 2.5, adjusts per rating
    pub interval_days: i64,     // Current interval
    pub repetitions: u32,       // Consecutive successful reviews
    pub due_date: DateTime<Utc>,
    pub last_reviewed: Option<DateTime<Utc>>,
}
}

Deck

#![allow(unused)]
fn main() {
pub struct Deck {
    pub id: u64,
    pub name: String,
    pub created_at: DateTime<Utc>,
    pub cards: Vec<Card>,
    pub reviews: Vec<Review>,
}
}

Review

#![allow(unused)]
fn main() {
pub struct Review {
    pub card_id: u64,
    pub timestamp: DateTime<Utc>,
    pub quality: u8,            // 1=Again, 2=Hard, 3=Good, 4=Easy
    pub interval_scheduled: i64,
}
}

SM-2 algorithm

The review function adjusts EF and interval based on quality:

#![allow(unused)]
fn main() {
pub fn review(&mut self, quality: u8, now: DateTime<Utc>) {
    let q = quality.clamp(0, 5);

    if q < 3 {
        // Failed: reset to 1-day interval
        self.repetitions = 0;
        self.interval_days = 1;
    } else {
        // Success: increase interval
        self.repetitions += 1;
        self.interval_days = match self.repetitions {
            1 => 1,
            2 => 6,
            _ => (self.interval_days as f64 * self.easiness_factor).round() as i64,
        };
    }

    // Adjust easiness factor
    let ef_change = 0.1 - (5 - q) as f64 * (0.08 + (5 - q) as f64 * 0.02);
    self.easiness_factor = (self.easiness_factor + ef_change).max(1.3);

    self.due_date = now + Duration::days(self.interval_days);
    self.last_reviewed = Some(now);
}
}

Data persistence

All data is stored in a single JSON file (data.json):

{
  "decks": [
    {
      "id": 1,
      "name": "Japanese",
      "cards": [...],
      "reviews": [...]
    }
  ],
  "next_id": 2
}

Save strategy

  • Save after each rating during study
  • Save after importing decks on startup
  • Save on clean exit
  • Synchronous write (fine for small data; ~ms latency)

Data directory

The app uses XDG_DATA_HOME if set (some launchers provide it). Otherwise it resolves the data directory from the binary’s location, so data.json and deck files live next to the executable regardless of working directory. For development, it falls back to the current directory.

Building

Prerequisites

macOS (development)

brew install sdl2 sdl2_ttf

Linux (Debian/Ubuntu)

sudo apt install libsdl2-dev libsdl2-ttf-dev

Build commands

# Native macOS build
just native

# Run locally
just run

# Cross-compile for RG35XXSP via Docker
just docker-aarch64

# Create release zip
just package

# Run tests
just test

# Check (fast type-check)
just check

Build profiles

Release

[profile.release]
opt-level = 3
lto = true
strip = true
panic = "abort"
  • lto = true: Link-time optimization reduces binary size
  • strip = true: Remove debug symbols
  • panic = "abort": Smaller binary, no unwinding

Typical release binary: ~500 KB

Development

[profile.dev]
opt-level = 1

Fast compilation for iteration.

Cross-compilation details

The Docker image (scripts/Dockerfile.build) is based on Ubuntu 20.04 for glibc compatibility. It builds:

  • SDL2 2.26.2 from source (avoids macOS Metal symbol issues)
  • SDL2_ttf 2.20.2 from source (includes bundled FreeType/HarfBuzz)
  • Rust stable via rustup

Native Linux approach

Requires aarch64-linux-gnu-gcc toolchain and aarch64 SDL2 libraries:

sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
sudo apt install libsdl2-dev:arm64 libsdl2-ttf-dev:arm64

The scripts/build-aarch64.sh script detects macOS and refuses to run (since macOS SDL2 libs are incompatible).

PortMaster Packaging

PortMaster is the distribution mechanism for homebrew on handheld Linux devices (Knulli, ArkOS, AmberELEC, etc.).

The portmaster/ folder

The portmaster/ folder in the project root contains the source files that get packaged for distribution. It’s separate from src/ (application code) because these files are metadata and scripts for the PortMaster ecosystem, not the app itself.

FilePurposeConsumed by
Cardagain.shLaunch scriptPortMaster at runtime
port.jsonApp metadataPortMaster repository index
gameinfo.xmlEmulationStation metadataEmulationStation UI
control.txtController → keyboard mappinggptokeyb at runtime
README.mdPortMaster wiki docsPortMaster GUI / website
fonts/Bundled open-source fontsThe application at runtime

The scripts/package-portmaster.sh script copies these files, adds the compiled binary, and creates the final zip.

Package structure

cardagain-0.1.0.zip
├── Cardagain.sh          # Launch script (extracted to roms/ports/)
└── cardagain/              # Game directory
    ├── cardagain          # ELF aarch64 binary
    ├── control.txt          # gptokeyb controller mapping
    ├── port.json            # PortMaster metadata
    ├── gameinfo.xml         # EmulationStation metadata
    ├── README.md            # PortMaster wiki docs
    └── fonts/
        └── DejaVuSans.ttf   # Bundled font

Required files

Cardagain.sh

The launch script. PortMaster sets environment variables like $GAMEDIR, $CONFDIR, $GPTOKEYB before calling it.

Key responsibilities:

  • cd to the game directory
  • Set XDG_DATA_HOME for save files
  • Run gptokeyb for controller → keyboard mapping
  • Launch the binary
  • Clean up on exit

port.json

PortMaster metadata for the repository index:

{
  "version": 2,
  "name": "cardagain.zip",
  "items": ["Cardagain.sh", "cardagain"],
  "attr": {
    "title": "Cardagain",
    "desc": "Spaced Repetition Flashcards",
    "rtr": true,
    "arch": ["aarch64", "armhf"]
  }
}

gameinfo.xml

EmulationStation metadata:

<gameList>
  <game>
    <path>./Cardagain.sh</path>
    <name>Cardagain</name>
    <desc>...</desc>
    <genre>puzzle,educational</genre>
  </game>
</gameList>

control.txt

gptokeyb mapping file. Maps controller buttons to keyboard keys the game expects:

a = space    # A button → Space key
b = x        # B button → X key
x = c        # X button → C key
y = v        # Y button → V key
menu = esc   # Menu → Escape

Packaging script

scripts/package-portmaster.sh creates the zip:

  1. Finds the aarch64 binary (or warns if using native)
  2. Stages files into a temp directory
  3. Bundles fonts from portmaster/fonts/
  4. Creates the zip with Cardagain.sh at the root

Installation on device

  1. Copy cardagain-0.1.0.zip to roms/ports/
  2. Extract:
    cd /roms/ports
    unzip cardagain-0.1.0.zip
    
  3. Launch from EmulationStation: PortsCardagain

Submitting to PortMaster

To add to the official repository:

  1. Fork PortsMaster/PortMaster-New
  2. Add your files under ports/cardagain/
  3. Submit a PR

The PortMaster team reviews and merges. Once accepted, users can install directly from the PortManager GUI.

ADR 0001: Rust + SDL2 for Handheld Development

Status

Accepted

Context

We need to build a flashcard app for the Anbernic RG35XXSP (aarch64 Linux, 1GB RAM, 640×480 screen). The device runs Knulli, a Batocera fork with SDL2 pre-installed for emulators.

Options considered:

OptionProsCons
Rust + SDL2Fast, small binaries, SDL2 already on deviceMust cross-compile from macOS
Python + PygameEasy developmentLarge runtime, slow startup
C + SDL2Fastest, smallestManual memory management, slower development
GodotRich UI, visual editorToo heavy for simple app, overkill
Love2D (Lua)Simple, proven for gamesLua runtime overhead, less type safety

Decision

Use Rust with SDL2.

Consequences

Positive

  • Binary size: ~500 KB (vs Python’s ~50MB+ runtime)
  • Startup time: Instant (vs Python’s import overhead)
  • Memory usage: <20 MB total (fits comfortably in 1GB)
  • SDL2 is already on Knulli — no additional runtime deps
  • Type safety prevents common C bugs

Negative

  • Cross-compilation from macOS requires Docker (SDL2 library format mismatch)
  • Rust compile times are slower than Python
  • Smaller ecosystem than Python for some tasks

Notes

The SDL2 window/renderer API is low-level but sufficient for a flashcard app. We don’t need GPU acceleration or complex UI widgets. The sdl2 crate provides safe bindings and sdl2_ttf handles text rendering.

ADR 0002: SM-2 Algorithm for Spaced Repetition

Status

Accepted

Context

We need a scheduling algorithm for flashcard reviews. The app targets users who want Anki-style spaced repetition on a handheld device.

Options considered:

AlgorithmProsCons
SM-2 (SuperMemo-2)Proven, simple, same as AnkiLess sophisticated than newer algorithms
FSRS (Free Spaced Repetition Scheduler)Machine learning, more accurateComplex, needs training data, harder to implement
SM-17 (SuperMemo-17)Most advancedProprietary, extremely complex
Custom intervalSimpleNo optimization, poor retention
Leitner systemSimple, visualLess efficient than SM-2

Decision

Use SM-2.

Consequences

Positive

  • Same algorithm as Anki — users already understand it
  • Simple to implement (~30 lines of Rust)
  • No machine learning dependencies
  • Well-studied, proven effective for decades
  • Easy to debug and reason about

Negative

  • Doesn’t adapt to individual user patterns as well as FSRS
  • Fixed EF change formula; newer research suggests improvements
  • No optimization for specific card types

Implementation

#![allow(unused)]
fn main() {
pub fn review(&mut self, quality: u8, now: DateTime<Utc>) {
    let q = quality.clamp(0, 5);

    if q < 3 {
        self.repetitions = 0;
        self.interval_days = 1;
    } else {
        self.repetitions += 1;
        self.interval_days = match self.repetitions {
            1 => 1,
            2 => 6,
            _ => (self.interval_days as f64 * self.easiness_factor).round() as i64,
        };
    }

    let ef_change = 0.1 - (5 - q) as f64 * (0.08 + (5 - q) as f64 * 0.02);
    self.easiness_factor = (self.easiness_factor + ef_change).max(1.3);
    self.due_date = now + Duration::days(self.interval_days);
    self.last_reviewed = Some(now);
}
}

Future work

Could migrate to FSRS later if user demand exists. SM-2 data is compatible — we’d just recompute intervals with the new algorithm.

ADR 0003: CSV Deck Import

Status

Accepted

Context

Users need to add custom flashcard decks. The device has no keyboard, so in-app card creation is impractical. We need a simple way to bulk-import cards from a computer.

Options considered:

FormatProsCons
CSVUniversal, editable in any spreadsheet, simpleNo metadata, flat structure
JSONMatches internal format, supports metadataHarder to edit manually
Anki .apkgDirect Anki compatibilityComplex format, needs parser
TSVLike CSV, no comma escaping issuesLess universal
Markdown tablesHuman-readableHarder to parse reliably

Decision

Support CSV as primary, JSON as secondary.

Consequences

Positive

  • CSV is editable in Excel, Google Sheets, Numbers, Vim, anything
  • One file per deck keeps things organized
  • Simple format: front,back — no learning curve
  • Comments (#) and empty lines are ignored for flexibility
  • Duplicate detection prevents accidental re-imports

Negative

  • No support for card metadata (tags, images, audio)
  • Commas in card text need careful escaping (we split on first comma)
  • No deck hierarchy or subdecks
  • Can’t import Anki decks directly

Format

# Lines starting with # are comments
Hello,こんにちは
Thank you,ありがとう
Good morning,おはよう

Rules:

  • Filename = deck name (e.g., Japanese.csv → deck “Japanese”)
  • One card per line: front,back
  • First comma splits front from back (rest of line is back)
  • UTF-8 encoding required for non-Latin characters

Future work

  • Anki .apkg import (popular request, complex to implement)
  • .tsv support (trivial addition if requested)
  • In-app card editor (for small corrections, not bulk creation)

ADR 0004: Docker for Cross-Compilation

Status

Accepted

Context

Primary development is on macOS (Apple Silicon), but the target device runs Linux aarch64. macOS cannot cross-compile SDL2 apps directly because macOS SDL2 libraries (.dylib, Mach-O format) are incompatible with the Linux aarch64 cross-compiler (needs .so, ELF format).

Options considered:

ApproachProsCons
DockerSame environment every time, works on any host, reproducibleSlower first build (image creation)
WSL2Native Linux on Windows, can share filesWindows only, setup complexity
Virtual MachineFull Linux environmentHeavy, slow, manual maintenance
Remote Linux serverFast, always-onRequires server, network dependency
Build on deviceNo cross-compilation at allSlow compilation on H700, needs dev tools on device
nix-shellReproducible, declarativeSteep learning curve, nix adoption

Decision

Use Docker as the primary cross-compilation method. Provide build-on-device as fallback.

Consequences

Positive

  • Works on macOS, Linux, and Windows (with Docker Desktop)
  • Container has exact SDL2 version we need (2.26.2)
  • No host dependency contamination
  • Reproducible builds
  • Apple Silicon Macs run aarch64 Linux containers natively (no emulation)

Negative

  • Docker image is ~2.5 GB (Ubuntu 20.04 + SDL2 build + Rust toolchain)
  • First build takes ~2 minutes to create the image
  • Container startup adds ~5 seconds per build
  • Requires Docker Desktop installed

Implementation

scripts/Dockerfile.build creates the image:

FROM ubuntu:20.04

# Install build dependencies
RUN apt-get install -y build-essential libsdl2-dev libsdl2-ttf-dev ...

# Build SDL2 2.26.2 from source (avoids macOS Metal symbol issues)
RUN wget https://github.com/libsdl-org/SDL/archive/refs/tags/release-2.26.2.tar.gz
...

# Build SDL2_ttf 2.20.2 from source
RUN wget https://www.libsdl.org/projects/SDL_ttf/release/SDL2_ttf-2.20.2.tar.gz
...

# Install Rust
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
RUN rustup target add aarch64-unknown-linux-gnu

scripts/docker-build.sh runs the build:

docker build -t cardagain-build -f scripts/Dockerfile.build .
docker run --rm -v $(pwd):/workspace -w /workspace cardagain-build \
    bash -c 'cargo build --release --target aarch64-unknown-linux-gnu'

Future work

  • Multi-stage Dockerfile to reduce image size
  • Cache Rust dependencies between builds
  • Publish pre-built image to Docker Hub or GHCR