Skip to content

Latest commit

 

History

History
441 lines (376 loc) · 21 KB

AGENTS.md

File metadata and controls

441 lines (376 loc) · 21 KB

DB48X Project Guide

Project Overview

DB48X is a modern implementation of RPL (Reverse Polish Lisp) targeting SwissMicros calculators (DM32, DM42, DM42n). It's a complete calculator firmware written in C++ with:

  • An RPL object system with compact LEB128 encoding
  • Qt-based simulator for development/testing
  • Firmware builds for ARM Cortex-M4 (DM42/DM32 hardware)
  • Android and WebAssembly builds

General Guidelines for AI Assistants

  • Do not stack unrelated code changes. If the user is in the middle of developing feature A, avoid introducing unrelated changes (B, C) into the same edits; they can be hard to deinterlace for clean commits. If the repository is clean and a git operation would make it easy to restore the earlier state, then making extra changes is OK. Otherwise, add ideas to the todo list to be done later in a separate commit.
  • Commits must contain the user's Signed-off-by line in addition to the Co-authored-by mentioning the model. Do not invent a Signed-off-by address, when in doubt check earlier commits for that author in the history.
  • Never push anything without being explicitly asked to for that commit A request to push for one commit is not an authorization to push later ones.
  • Avoid overwriting the user's edits. When making changes, the user may have adjusted files themselves. Read the current file state before editing; prefer minimal, targeted edits that accomplish the goal without clobbering unrelated changes the user has made.
  • Use rg (ripgrep) instead of find/grep when searching. The rg command is faster and more battery-efficient than find or grep. Examples:
    • rg "pattern" instead of grep -r "pattern"
    • rg -t cpp "pattern" to search only C++ files
    • rg -l "pattern" to list files (like grep -l)
    • rg --files | rg "\.h$" instead of find . -name "*.h"
  • Do not add case-only aliases in ids.tbl. Command parsing is case-independent, so LIMIT, Limit, and limit all refer to the same command once it is registered. Reserve ALIAS for spellings that differ in more than capitalization (e.g. TAYLR for Taylor, or PartialFractions for PartFrac). Symbol names are also case-independent by default (IgnoreSymbolCase): X and x are the same, as are ABC, abc, and abC. Do not treat capitalization as a distinct variable in docs, tests, or debugging. This does not apply to ⓧ (VX): that object names the current algebra variable (STOVX / RCLVX), defaults to x, and after 'ABC' STOVX denotes ABC — not x/X.

Build System

Simulator (for development/testing):

make -j sim                         # Build Qt simulator
./db48x.app/Contents/MacOS/db48x    # Run on macOS
./db48x                             # Run on Linux

Firmware:

make -j8                # Build DM42 firmware (db48x.pgm)
make -j8 debug          # Debug build
make -j8 dm32           # Build for DM32 (db50x.pg5)

QSPI CRC rebuilds: If you see "Run build once more", run make again. QSPI is flash memory containing numerical routines; the build forces CRC compatibility with original DM42 firmware to allow switching between DB48X and stock DM42 firmware.- help / db48x.md and help / db50x.md are **auto - generated **by the build.Never edit them directly; edit doc / commands/*.md instead.

  • Documentation internal links: use only the anchor part, e.g. (#Anchor), not (settings.md#Anchor). The shipped help (db48x.md, db50x.md) is built by concatenating and sed-editing the input markdown files into a single file, so cross-file links break.
  • Command cross-references in docs: to reference another command, simply use backtick syntax, e.g. `ToRectangular`. The help rendering engine automatically links backtick-quoted command names to their documentation. Any spelling or alias works; the engine displays the current style. Do not use explicit markdown links for command cross-references.
  • No duplicate entries for alternate spellings: the help system automatically displays all alternate spellings for a command. Do not create separate ## entries for aliases or compatibility spellings. Document each command once under its primary name.
  • No markdown bold for emphasis in command docs: do not write **foo** in doc/commands/*.md to highlight words. The built-in help renderer does not display bold correctly, and it is easy to overuse. Use backticks for command names, settings, and literal RPL fragments; otherwise write plain prose.

Adding a New Command

  1. src/ids.tbl: Add NAMED(Foo, "→Foo") or CMD(Foo). Use ALIAS only for spellings that differ in more than case (see general guideline above). Prefer NAMED(ShortName, "LongSpelling") when the C++ identifier is short (e.g. NAMED(PRoot, "PolynomialRoots")). Update any ID_RANGE lines whose boundary the new command changes (e.g. is_command, is_algebraic_fn).
  2. Header (e.g. src/functions.h): Declare with FUNCTION(Foo), COMMAND_DECLARE(Foo, nargs), COMMAND_DECLARE_FN(Foo, nargs), or STANDARD_FUNCTION(Foo) as appropriate (see below).
  3. Implementation (e.g. src/functions.cc): Implement with FUNCTION_BODY(Foo) or COMMAND_BODY(Foo).
  4. src/menu.cc: Wire up menu entries (replace ID_Unimplemented if a placeholder exists).

Commands in Algebraic Expressions

Commands that should be usable in algebraic expressions (like 'JDN(DateTime)') must use COMMAND_DECLARE_FN instead of COMMAND_DECLARE:

  • COMMAND_DECLARE_FN(name, arity) adds PREC_DECL(FUNCTION) for function precedence
  • Makes the command parseable and callable inside algebraic expressions
  • Use for: mathematical functions, conversion functions, any fixed-arity command that returns one result and should work in expressions

Note: SYMARGS and SYMARGS_DECL are separate and only needed for functions that require arguments to stay symbolic/unevaluated (like Sum, Integrate, Quote). Most functions just need COMMAND_DECLARE_FN, not SYMARGS.

Example:

// In header (src/datetime.h):
COMMAND_DECLARE_FN(JulianDayNumber, 1);  // Can be used in expressions

Then in RPL:

@ Works in both RPN and algebraic mode:
2461178.5 JDN→                           @ RPN: OK
'JDN→(2461178.5)'                        @ Algebraic: OK (see commit 40c59d837)

Variable arity functions (like ToRange which takes 1 or 2 arguments) use negative arity notation:

COMMAND_DECLARE_FN(ToRange, ~2);  // ~2 means "at least 2, variable arity"

During expression parsing, variadic functions are wrapped in funcall to handle argument count correctly. The infrastructure provides variable_arity() and arity() helpers.

Menu layout

  • Menus display 6×3 = 18 entries when there are fewer than 18; otherwise 5×3 + next/previous keys (15 visible, paginated).
  • Group entries logically (vertically or horizontally). Alignment matters for usability.
  • When adding a menu entry, ensure the count stays ≤18 or that the grouping still makes sense with pagination. If adding one entry would exceed 18, consider removing another that is available elsewhere (e.g. via shortcuts or other menus).

Running Tests

  • Build the simulator first: make -j sim
  • Quick RPL checks (no rebuild): use simulator -E to evaluate a command line and print the stack, e.g. db48x -H -E '[1 2 3] PROOT'. Read from stdin with -F -, e.g. db48x -H -F - -E '1 2 +'. See SIMULATOR.md.

Quick testing examples from the test suite:

Using simulator -E flag for quick RPL checks:

./db48x.app/Contents/MacOS/db48x -H -E "Clear 'JDN(20250919_date)' →Num"

Getting default RPL spelling for commands

To get the default RPL spelling for commands with aliases, you can use the following trick:

db48x -H -E "{ ToPolar PolarToReal RealToPolar }"

This produces a list containing the actual spellings after parsing:

{ →Polar Polarℂ→ℝ ℝ→Polarℂ }
  • Before submitting changes, run the full test suite and fix any failures. From the repository root (after building the simulator):
    db48x -H -Tall
    
    The -H flag runs in headless mode (no Qt window). Exit code 0 means all tests passed; non-zero means there are failures (see console output or failures.log if you capture it).
  • Treat every test failure as your problem until proven otherwise. The user typically invokes you with a clean test suite. Do not dismiss failures as “pre-existing”, “unrelated”, or “environmental” unless you have proven that (e.g. reproduced on the parent commit, or isolated the failure to code you did not touch). Failures in tests that run after your new tests are often caused by state leakage (settings, reserved variables such as PPAR, directories, flags). Investigate before claiming innocence.
  • To run a single test group: db48x -H -Tname (e.g. -Tsectrig).
  • Enable recorder traces during tests with -tchannel (see Debugging section):
    db48x -H -Ttoqpi -talgebraic
    
  • All new features and bug fixes must include tests. Build and run the full suite (db48x -H -Tall) before considering work complete.

Platform notes:

  • Run simulator from repo root so help/ and config/ directories are found
  • DMCP: SwissMicros Device Management Control Program (calculator firmware platform)
  • QSPI: Quad-SPI flash region storing numerical routines

Adding Tests

  • Test functions go in src/tests.cc, declared in src/tests.h.
  • Every test function using BEGIN(name) must have a corresponding TESTS(name, "description") macro near the top of tests.cc (around lines 89-160). Without it, the check_<name> function won't exist and the sim build will fail.
  • Register the function call in the test runner (around line 237).
  • The calculator works at 24-digit precision by default. Test inputs typed as literal decimals must have enough digits, or use computed values (e.g. "pi →Num 2 /" instead of "1.5707963268").
  • Default fraction display uses superscript/subscript (¹/₄). Use "BigFractions ImproperFractions" in tests for plain 1/4 format, and restore with "SmallFractions MixedFractions" at the end.
  • Restoring settings: when a test changes a setting (e.g. 10000 FractionLargestPrime), restore the default at the end using Purge: { FractionLargestPrime } Purge Std. The Std reapplies standard defaults. See MaxFactorIterations and FractionLargestPrime in tests.cc for examples.
  • Sandbox directories: when a test modifies reserved variables, global state, or directory contents that later tests depend on, run it inside a temporary directory: 'MyTest' CRDIR, enter it, run the test, then UPDIR and 'MyTest' PURGE. See SLVTST in solver_testing, ExamplesTest in check_help_examples, and PlotIndepTest in plotting for examples. Plotting tests that change PPAR must not run in the main directory unless the prior state is fully restored before subsequent image-based plot tests.
  • Test assertions (.expect, .want, .match):
    • Use .expect() for exact match.
    • Use .want() when you only need to ignore/allow variation in spaces (e.g. matrices and vectors, which may include newlines or extra spaces).
    • Use .match() when the expected value is easier to express with a regexp.
  • Vectors and matrices cannot contain non-algebraic expressions directly. Use [ 0.25 '√2' ] →Num instead of [ 0.25 2 √ →Num ] when you need a vector or matrix of decimals for testing.
  • Lists are not evaluated in RPL. { 1.5 neg 2.3 } builds a list containing the literal objects 1.5, neg (the operator), and 2.3 — not -1.5. For a list with negative numbers, use { -1.5 2.3 } (literal minus). Using neg inside a list leads to "Bad argument type" when applying →Q or similar commands that expect numeric elements.

User visible tests

User-visible tests Document RPL commands and functions in the topical markdown files under doc/. This is introduced with an RPL block that looks like this and is automatically scanned by the test suite

1 2 +
@ Expecting 3

See tests::check_help_examples() for the code that scans and evaluates all these code snippets. Avoid comments in the code snippets since they are part of the documentation and can be copy/pasted by the user. Usually, the comments should go above the RPL code snippet, i.e. write something like this:

Adding integers produces the result on the stack:

```rpl
1 2 +
@ Expecting 3
```

rather than

```rpl
@ Adding integers produces the result on the stack:
1 2 +
@ Expecting 3
```

RPL syntax in tests

Remember that RPL uses @ for comments, not // or #

The @ Expecting fragments needs the space between @ and Expecting

The test suite cleans up between RPL code snippets, except if they contain a comment that begins with @ Keep or @ Save, in which case the variables and other changes made by the code snippet are visible to the next one.

Architecture Notes

  • DB48X uses C++ in a very special way. Object values are not stored in the object itself, but as LEB128 encoding in the object. RPL objects can be moved in memory, garbage collected, etc. They are byte-aligned and very compact. It is NOT ALLOWED EVER EVER EVER to use a virtual function, because that would add at least one word to the object for the vptr, which can't be copied or moved willy-nilly. Dispatch is done using obj->type() and a switch statement (or the handler table), not virtual functions.

  • For the same reason, it is NOT OK TO USE THE STANDARD LIBRARY, for example std::vector, because on device firmware there is no real malloc(), no ecception handling, etc. Using std::vector results in unsatisfied symbols building the firmware, and the correct approach is to avoid using std::vector, not to try random disastrous repairs to bring the firmware up to standard.

  • src/ids.tbl is the single source of truth for all object/command IDs. It is #included with varying macro definitions to generate enums, spelling tables, and dispatch tables.

  • Promote reusable helpers to shared modules: do not multiply local static helpers when an existing shared utility would do better. Before adding local helpers, check the relevant subsystem (runtime.*, complex.*, factor.*, etc.). If the logic is generic and reused, move it to the appropriate shared file and call it from feature code.

  • Dispatch: object::handler[ID_Foo].evaluate calls Foo::do_evaluate.

  • For FUNCTION-style commands: the evaluate chain is do_evaluate -> evaluate() (static) -> evaluate(algebraic_r x).

  • Smart pointer conventions (_p, _g, _r):

    • Prefer _p return types unless there is a type-related need for _g (e.g. the caller must extend lifetime / adopt a new allocation).
    • Use _r for arguments unless the callee would need to construct a _g inside (e.g. to hold a temporary).
    • _g = GC-safe owning pointer; _p = non-owning raw pointer; _r = reference.
  • Arithmetic operators (+, -, *, /) are overloaded for algebraic_r. Multiplying a numeric type by a symbolic type (e.g. constant) will typically try numeric evaluation, not create a symbolic expression. Use expression::make(ID_multiply, x, y) to build symbolic products explicitly.

  • constant::lookup("π", false) returns a symbolic constant (the false suppresses errors if not found). algebraic::pi() returns a numeric value.

The cleaner purge pattern

The cleaner purge pattern is a lightweight memory reclamation technique that complements garbage collection. It reclaims intermediate temporaries without running a full GC cycle.

Usage pattern:

cleaner purge;                              // Save current Temporaries pointer
algebraic_g result = evaluate(op, y, x);    // Do work (may create temps)
if (+result != +x && +result != +y)         // Check if result is new
    result = purge(result);                 // Purge intermediate temporaries
return result;

How it works:

  1. cleaner purge; saves the current rt.Temporaries pointer and GC cycle count
  2. During computation, intermediate temporary objects may be allocated above that mark
  3. purge(result) checks if:
    • No GC ran during the operation (cycle count unchanged)
    • The result is a new temporary (above the saved mark)
    • Automatic cleanup is enabled in settings
  4. If all conditions hold, it discards all temporaries between the saved mark and the result, keeping only the result by moving it down to the saved position
  5. If GC ran (objects may have moved), purge() returns the result unchanged

When to use:

  • At the end of functions that create multiple intermediate objects but return one final result
  • The idiom is cleaner purge; at function start, then purge(result) before returning

The input check: Always verify the result is new before purging:

if (+result != +input1 && +result != +input2)
    result = purge(result);

If you skip this check and the result aliases an input still on the stack, purging would move that stack object, corrupting the stack.

Statistics: The runtime tracks both memory reclamation mechanisms separately:

  • rt.GCPurged - bytes reclaimed by full garbage collection
  • rt.GCCleared - bytes reclaimed by cleaner purge operations

Type ranges (ID_RANGE) — critical gotcha

The ID_RANGE(is_integer, ...) range covers IDs from based_integer through neg_integer. Because of the ordering in ids.tbl, bignums fall inside this range (bignum and neg_bignum have IDs between based_integer and neg_integer). Therefore object::is_integer(ID_bignum) returns true.

When extracting numeric values, always check is_bignum before is_integer and use bignum_p(…)->value<ularge>(). Casting a bignum via integer_p gives garbage because integer uses LEB128 encoding while bignum inherits from text (length-prefixed raw bytes). Similarly, is_fraction includes big_fraction, so fraction_p(…)->numerator_value() works for both, but be aware of the overlap.

The general rule: narrow type checks (is_bignum, is_big_fraction) before wide ones (is_integer, is_fraction) when the code path depends on the memory layout.

decimal::to_fraction() returns a bignum (not integer) for whole-number results, because it works with bignum_g internally.

Debugging with the Recorder

Use the project's recorder library instead of fprintf(stderr, ...).

  1. Each source file declares recorder channels near the top: RECORDER(algebraic, 16, "RPL Algebraics");
  2. Log with record(channel, "format %+s %u %p", ...):
    • %+s for utf8 / object::name(ty) strings
    • %t for readable object dumps (objects rendered like the stack display)
    • %u, %d for integers, %p for pointers
  3. Recorder output is ring-buffered and low-overhead; safe to leave in production code (unlike fprintf). Never use fprintf for debugging.
  4. Enabling traces at runtime: use the -t flag on the simulator command line. The flag takes a comma-separated list of recorder channel names:
    ./db48x.app/Contents/MacOS/db48x -Ttoqpi -talgebraic
    ./db48x.app/Contents/MacOS/db48x -Tall -talgebraic,fraction,expression
    
    You can also use the RECORDER_TRACES environment variable: RECORDER_TRACES=algebraic ./db48x ...
  5. Trace output is interleaved with test results on stderr, formatted as: [sequence timestamp] channel: message
  6. For new functionality, add record() calls at key decision points (type dispatch, computed values, best-candidate selection) so future debugging doesn't require adding and removing fprintf statements.

Code Style

  • Section comments (grouping multiple functions/structs): use // ==== on 5 lines:
    // ============================================================================
    //
    //   Section title
    //
    // ============================================================================
    
  • Function/struct headers: // ---...--- separator lines around bodies.
  • One-line header only: The comment between the first // ---- lines after a function name must be strictly one line. If it can't be summarized in one line, it's too verbose. Put details below the second // ---- line.
  • No narrating comments; comments explain non-obvious intent only.
  • Use object::ID_foo (not bare ID_foo) in static/non-member functions where the id typedef is not in scope.
  • No hanging statements: every for, while, if, etc. that controls more than a trivial single statement must use braces. Even when the body is a single sub-statement (e.g. a while inside a for), wrap the outer loop body in braces. Allman-style brace placement (opening brace on its own line).
  • Line length: if a statement fits within 80 columns, keep it on one line; avoid gratuitous line breaks.
  • When unsure how to format a section of code, run it through clang-format.