Doomsday SD App Development and Packaging Guide — API 3
This is the complete beginner-to-release guide for applications running on ESP32 DoomsDay firmware 3.15.0 / Phase 3.15. It covers creating a project, understanding its files, writing Doomsday App Script (DAS), drawing a usable 128 × 128 interface, requesting permissions, adding sprites and private data, building a .dtapp, installing and updating it, testing it, and diagnosing common errors.
API 3 is the current SD application contract. An app is distributed as one .dtapp ZIP-STORE archive containing a stable UUID manifest, an interpreted UTF-8 DAS source script, a graphical icon, and optional sprite resources. The archive can live in any user-managed SD-card folder.
Installation validates and extracts the package transactionally into the protected /apps tree on that same card, commits the registry, and leaves the portable source archive untouched. Each installed UUID also receives isolated storage under /appdata. Apps remain bounded interpreted programs: hardware services are available only through declared DAS operations and the user's per-app permission policy; apps never receive arbitrary memory or firmware-code access.
API 2 packages remain installable. API 3 adds required category/description metadata, declared capabilities, private persistent values, calendar events, sensor/system services, Wi-Fi result inspection, and controlled radio-channel services.
What you need
App development does not require compiling or flashing the firmware. The included packaging tools use only Python's standard library.
- A checkout/copy of this firmware repository.
- Python 3.10 or newer available as
python(Python 3.9 is normally sufficient, but 3.10+ is the supported recommendation for new developers). - A plain-text editor such as VS Code, Notepad++, or another editor that preserves UTF-8 text.
- A FAT32 SD card for installation on the device.
- The device firmware should be API 3 capable; this guide targets firmware 3.15.0.
Open PowerShell, Terminal, or a shell in the repository root, the directory containing tools, examples, and this guide. Verify Python:
python --version
python tools/new_dtapp.py --help
No pip install is required. PlatformIO is needed only when changing the firmware itself, not for a DAS app.
Ten-minute quick start
Create a unique project. The slug becomes the source directory and output archive name, so use lowercase letters, digits, and single hyphens:
python tools/new_dtapp.py field-notes --name "Field Notes" --category Utilities --description "Compact field counter and notebook" --permissions storage
The scaffold creates:
examples/apps/field-notes/
manifest.ini app identity, metadata, permissions, entry points
main.das interpreted application logic and drawing
assets/
icon.sprite editable 16 × 16 icon source
Build it:
python tools/build_dtapp.py examples/apps/field-notes
Successful output looks similar to:
Built dist/apps/field-notes.dtapp [Field Notes 0.1.0 | <uuid>]
The builder compiles assets/icon.sprite into assets/icon.dsi, validates the manifest and every DAS instruction, writes a deterministic ZIP-STORE package, and verifies its CRCs. Do not manually zip or rename a normal ZIP as .dtapp.
Copy only dist/apps/field-notes.dtapp to a user-managed directory on a FAT32 SD card, for example /Downloads/field-notes.dtapp. Do not copy the editable project directory and do not extract the archive.
On the device:
- Open File Manager.
- Navigate to
field-notes.dtappand press SELECT. - Choose Open, review the package name, version, API, UUID, description, author, and requested permissions.
- Press SELECT on INSTALL APP.
- Return to the 3 × 3 main menu to launch the app directly, or open Apps from any menu style and select it.
- If it requests a capability, choose Allow Once, Always Allow, or Deny Once.
An installed app's management screen provides Launch, Permissions, Reset private data, Details, and Uninstall. Uninstall and reset use a confirmation modal. Uninstall removes the installed payload, private values, and stored permission policy, but never deletes the portable .dtapp archive.
Your first DAS app, explained
The generated main.das is already runnable. As a first edit, shorten its middle line for the small display so the complete file reads:
# Doomsday App Script API 3 starter.
:frame
CLEAR
TITLE "Field Notes"
TEXTC 46 ACCENT "Hello from API 3"
TEXTC 61 PRIMARY "Edit main.das"
TEXTC 82 DIM "SELECT updates counter"
NUM 59 94 GREEN $0
BUTTON SELECT increment
WAIT 50
GOTO frame
:increment
ADD $0 1
GOTO frame
Read it as a tiny event loop:
:framecreates a jump label.CLEARredraws the active theme background.TITLEdraws the firmware-style title bar, back marker, and battery indicator.TEXTCdraws centered text at an absolute screen y coordinate.NUMdraws variable$0; every variable starts at zero on launch.BUTTON SELECT incrementjumps only when a debounced SELECT press is waiting.WAIT 50finishes and displays this frame, then pauses for at least 50 ms. Every continuously running app needs a reachableWAITorEND.GOTO framebegins the next frame.:incrementhandles the button, adds one to$0, and returns to the frame.
Edit a visible string, rebuild, replace the .dtapp on the card, open it in File Manager, and select UPDATE APP. An update is recognized by UUID, not filename or display name.
Identity rule: keep the generated
idforever for updates to this app. Changing it creates a different app. Never copy another app's UUID.
Recommended edit/build/test loop
- Edit
manifest.ini,main.das, and.spritesources. - Save text as UTF-8. Keep DAS lines under 160 bytes.
- Build with
python tools/build_dtapp.py <project-directory>. - Fix every builder error; do not bypass validation by hand-making an archive.
- Run
python tools/stress_das.py <project-directory> --frames 5000for looping/interactive apps. - Copy the newly built
.dtappto SD and use UPDATE APP. - Test denied, one-time, and always-allowed permission paths when the app uses services.
- Increase
versionfor a release, while preservingid.
Filesystem and lifecycle
Portable archive examples:
/Downloads/orbit-runner.dtapp
/My Apps/climate-deck.dtapp
/releases/tools/field-notes.dtapp
Framework-owned state:
/apps/
registry.txt
<uuid>/
manifest.ini
main.das
assets/...
/appdata/
<uuid>/
values.ini
/system/
app_permissions.ini (per-card Deny/Ask/Allow policy)
diagnostic.tmp (created only during a self-test)
Do not place archives inside /apps, /appdata, or /system; File Manager protects those trees from generic rename, deletion, paste, compression, extraction, and installation mutations. Their contents may be inspected or copied out for diagnosis. Installation stages into /apps/.install-<uuid>. Updates retain /apps/.backup-<uuid> until validation and registry commit succeed. Uninstall stages into /apps/.uninstall-<uuid>. Atomic .new/.old files protect the registry, private values, and permission policy. Boot recovery cleans or restores interrupted transactions after reset, power loss, or card removal.
The firmware polls removable-media state. Removing the card safely stops a running SD app and clears stale registry/resource handles. Reinsertion remounts the card and reloads installed apps without rebooting.
Installed applications have no fixed count limit. /apps/registry.txt is an atomic SD-backed UUID catalogue, while firmware keeps only a small rotating manifest cache in RAM. SD capacity, FAT32 limits, package validation, and the space needed for transactional staging are the practical limits. Leave enough free space for an install/update copy and its rollback copy. The portable archive remains separate from the extracted installation. Deleting the archive after a successful installation does not uninstall the app, and uninstalling the app does not remove the archive.
Manifest
id=46edbae1-ddbc-40cb-affc-9b9061e65551
name=Orbit Runner
version=1.0.0
author=Doomsday SDK
category=Games
description=Sprite runner with persistent scores
icon=assets/icon.dsi
api=3
permissions=storage
entry=main.das
id: canonical lowercase RFC-4122 UUID, versions 1–5. This is the sole install/update identity. Generate it once and never reuse it for another app.name: 1–24 printable characters.version: 1–16 printable characters. Semantic versioning is recommended.author: 1–24 printable characters.category: 1–16 printable characters.description: 1–48 printable characters.api:3for this contract;2remains supported as a compatibility profile.permissions:none, or a comma-separated subset ofstorage,sensors,system,wifi,bluetooth,files,radio.entry: normalized relative.daspath, up to 48 characters.icon: normalized relative.dsipath, up to 48 characters. It must resolve to exactly one 16×16 frame.
manifest.ini must be at the project/archive root and must remain below 1536 bytes. Paths use /, are relative to the root, and cannot contain absolute prefixes, .., backslashes, drive prefixes, or unsafe control characters. The manifest references the compiled .dsi name even when the editable source is .sprite.
The scaffold generates a UUID. For an existing hand-created project, generate one with:
python -c "import uuid; print(uuid.uuid4())"
Use only the permissions the script actually needs. The builder rejects a service operation whose permission is undeclared. bluetooth and files are valid reserved policy names but expose no API 3 DAS operations, so a new app normally should not request them.
Permission model
Every declared capability appears under Apps → select app → Permissions. The device stores a policy by app UUID on that SD card:
- Deny never grants the service.
- Ask (the default) prompts at every launch and grants only for that launch session.
- Allow grants the service whenever that UUID launches from that card.
When a capability is in Ask, launch presents three session choices:
- Allow Once grants it for this launch and keeps the stored policy at Ask.
- Always Allow grants it now and persists Allow.
- Deny Once withholds it for this launch and keeps the stored policy at Ask.
BACK cancels the entire launch. If several capabilities are Ask, the device prompts for each in turn. An operation reached without a launch-time grant stops the app with a controlled Permission denied screen; it does not silently substitute a value.
Changing the display name, directory, or archive location cannot bypass policy because identity is the permanent UUID. Permissions are checked both by the desktop builder and device runtime:
| Capability | Grants | DAS operations |
|---|---|---|
storage | This UUID's private values and calendar-event records | LOADKV, SAVEKV, DELKV, EVENTGET, EVENTSET, EVENTDEL |
sensors | Read-only firmware-owned environmental readings | SENSOR |
system | Read-only bounded device statistics | SYSINFO |
wifi | Read-only results and bounded analysis of the latest firmware Wi-Fi scan | WIFICOUNT, WIFIRSSI, WIFICHANNEL, WIFISECURITY, WIFINAME, WIFIBSSID, WIFICONGESTION, WIFIBESTCHANNEL, WIFIDUPLICATES |
radio | Current Wi-Fi channel and controlled channel selection | RADIOCHANNEL, RADIOSETCHANNEL |
bluetooth | Reserved policy slot for future Bluetooth DAS services | none in API 3 |
files | Reserved policy slot for future shared-file DAS services | none in API 3 |
none | Drawing, input, date/time, math, sprites | no service operations |
No current permission grants arbitrary app-private paths, flash writes, raw sensor buses, GPIO, or firmware-code access. A package that invokes an undeclared capability is rejected by the builder; the device independently faults it if validation was bypassed or if the launch session did not receive the permission.
Private persistent values
Each app has up to 64 integer entries and a 4 KB serialized quota. Keys are 1–24 lowercase characters from a-z, 0-9, _, ., and -.
LOADKV $0 "best_score" 0
SAVEKV "best_score" $0
DELKV "best_score"
LOADKV uses its default if the key does not exist. Writes use an atomic replacement sequence. Reset private data in Apps removes all values for the selected UUID. Updating an app preserves its data; uninstalling removes it.
Calendar-style records use a bounded date/type service and the same isolated, atomic store:
EVENTGET $0 $year $month $day
EVENTSET $year $month $day $type
EVENTDEL $year $month $day
Years are 1970–2099, months 1–12, days 1–31, and event types 0–15. The runtime bounds the numeric fields but does not reject impossible month/day combinations such as February 31, so calendar apps must validate real dates themselves. Type 0 removes the event.
Permission-gated sensor, system, and radio services
Sensor values use fixed-point integers so DAS remains deterministic:
SENSOR VALID $0
SENSOR AHT_VALID $1
SENSOR BMP_VALID $2
SENSOR TEMP $3
SENSOR HUMIDITY $4
SENSOR DEWPOINT $5
SENSOR FEELS $6
SENSOR PRESSURE $7
SENSOR ALTITUDE $8
VALID,AHT_VALID, andBMP_VALIDreturn0or1and are hardware-availability probes.- temperature, dew point, and feels-like values are centi-degrees C; divide by 100 for whole degrees.
- humidity is centi-percent; pressure is deci-hPa; altitude is decimeters.
- requesting a value from absent hardware causes a controlled runtime fault, so probe first.
System services:
SYSINFO HEAP_KB $10
SYSINFO PSRAM_KB $11
SYSINFO SD_FREE_KB $12
SYSINFO UPTIME_S $13
Memory and SD-space values are non-negative signed 32-bit integers; large SD capacity is clamped safely. UPTIME_S is derived from the 32-bit millisecond clock and eventually wraps through the signed range, so it is not a permanent timestamp.
Wi-Fi scan services read the firmware's most recent scan without initiating hidden radio work:
WIFICOUNT $0
WIFIRSSI $index $1
WIFICHANNEL $index $2
WIFISECURITY $index $3
WIFINAME $index 5 34 PRIMARY
WIFIBSSID $index 5 44 DIM
WIFICONGESTION 6 $4
WIFIBESTCHANNEL $5
WIFIDUPLICATES $index $6
Indices may be literals or variables and must evaluate to 0..count-1. RSSI is dBm and channel is 1–13. The scan must have been run by the firmware first; an app cannot secretly start a scan. A count of zero is a normal result and must be handled before indexed calls.
WIFINAME index x y color and WIFIBSSID index x y color render bounded,
single-line identity text directly through the firmware display service. Text
that cannot fit is shortened with a stable ellipsis; it never becomes a moving
marquee. A hidden SSID is shown as <hidden>. Coordinates and colors follow
the normal drawing rules.
WIFICONGESTION channel target returns a bounded relative score from 0 to
999. It weights stronger networks more heavily and includes partially
overlapping channels up to four channels away. It is useful for comparisons
inside one captured scan; it is not utilization percentage, airtime, or proof
of interference.
WIFIBESTCHANNEL target compares the conventional 2.4 GHz non-overlapping
choices 1, 6, and 11 using that same score and returns the lowest-scoring
channel. It is a planning hint, not a regulatory or performance guarantee.
WIFIDUPLICATES index target counts captured BSSIDs advertising exactly the
same SSID, including the indexed BSSID itself. Multiple BSSIDs are normal on
mesh and enterprise networks, so do not label a duplicate count as an attack.
WIFISECURITY returns the current firmware's ESP-IDF wifi_auth_mode_t numeric value:
| Value | Advertised authentication |
|---|---|
| 0 | Open |
| 1 | WEP |
| 2 | WPA Personal |
| 3 | WPA2 Personal |
| 4 | WPA/WPA2 mixed |
| 5 | WPA2 Enterprise |
| 6 | WPA3 Personal |
| 7 | WPA2/WPA3 mixed |
| 8 | WAPI |
| 9 | Enhanced Open / OWE |
| 10 | WPA3 Enterprise |
Treat an unrecognized future value as Unknown instead of assuming it is secure. RADIOCHANNEL $0 reads the current primary Wi-Fi channel. RADIOSETCHANNEL value accepts 1–13 and requires the higher-trust radio permission; changing it can affect firmware connectivity and other radio screens, so ordinary applications should prefer read-only wifi services.
DAS execution model
DAS is an interpreted, case-insensitive, line-oriented language. The device indexes labels once at launch, starts execution at the first line, and preserves its 32 integer variables while the app remains open. All variables reset to zero on a new launch; use private storage for values that must survive exit/reboot.
Syntax rules:
- Separate tokens with spaces or tabs.
- Put text containing spaces inside double quotes:
TEXTC 50 PRIMARY "Hello world". - Quoted strings do not support escaped quotes; use an apostrophe or omit the quote character inside user-facing text.
- Blank lines and whole lines beginning with
#or;are comments. Inline comments are not supported because the extra tokens fail arity validation. - Labels are
:name, contain no whitespace or second colon, are case-insensitive, and are at most 24 characters. - Each UTF-8 source line is at most 159 bytes. Non-ASCII characters consume more than one byte and the built-in firmware font generally cannot render them usefully, so application UI text should normally be concise ASCII.
- There are at most 64 labels and 640 interpreter steps between
WAIT/ENDyields. One source line consumes one step, including blank/comment/label lines processed by the runtime. - Every operation has exact arity. The builder rejects missing and extra arguments.
Variables and operands:
- Variables are
$0through$31when read as operands. - A destination variable may be written as
$0or0;$0is recommended for clarity. - A bare number such as
7is a literal operand.$7means “the current value in variable 7.” - All values are signed 32-bit integers. Avoid arithmetic overflow.
- DAS has no strings in variables, arrays, functions, objects, floating-point values, or dynamic allocation.
The firmware owns BACK. A short BACK press exits the app to its launching screen. Holding BACK for two seconds opens the global Power menu. Apps can read UP, DOWN, LEFT, RIGHT, and SELECT; they cannot intercept BACK.
Control, input, arithmetic, and time
Complete syntax:
SET variable value
ADD variable value
SUB variable value
MUL variable value
DIV variable value
MOD variable value
ABS variable
NEG variable
RAND variable inclusive_min exclusive_max
MILLIS variable
DATE year_variable month_variable day_variable
IF left ==|!=|<|<=|>|>= right label
GOTO label
BUTTON UP|DOWN|LEFT|RIGHT|SELECT label
HELD UP|DOWN|LEFT|RIGHT|SELECT label
WAIT milliseconds
END
| Instruction | Exact behavior |
|---|---|
SET target value | Replaces the target variable. |
ADD, SUB, MUL | Applies integer arithmetic to the target variable. |
DIV, MOD | Integer divide/remainder; zero divisor faults the app. |
ABS, NEG | Absolute value or sign inversion of one variable. Avoid the signed minimum-value overflow edge. |
RAND target low high | Random integer in [low, high); high must be greater than low. |
MILLIS target | Stores the low signed 32-bit representation of milliseconds since boot. It wraps and becomes negative after roughly 24.9 days; use short differences, not permanent timestamps. |
DATE year month day | Stores configured local date when system time is valid; otherwise the firmware compile date. |
IF left operator right label | Case-insensitive jump when the integer comparison is true. Operators: == != < <= > >=. |
GOTO label | Unconditional jump. |
BUTTON key label | Consumes one debounced press event and jumps. Direction keys may repeat according to firmware input timing; SELECT does not auto-repeat. |
HELD key label | Jumps while the key is physically held. Use it with a nonzero WAIT to prevent runaway loops. |
WAIT milliseconds | Draws the framework action bar, presents the frame, and yields. Runtime clamps the delay to 1–5000 ms, even if the script requests outside that range. |
END | Cleanly exits to the launching firmware screen. |
Execution must reach WAIT or END within 640 processed source lines. A script with only GOTO loops faults even if the builder can see another unreachable WAIT. Large blocks of comments/labels also consume runtime steps. Put WAIT in the normal frame loop and keep button-handler paths short.
Division/modulo by zero, invalid operands, missing jump targets/resources, unavailable services, denied permissions, SD read failure, end-of-file without WAIT/END, and instruction-budget exhaustion produce a controlled App Error screen with the approximate script byte position.
Drawing and screen layout
Complete syntax:
CLEAR
TITLE "Title"
TEXT x y color "Text"
TEXTC y color "Centered text"
NUM x y color value
RECT x y width height color
FILLRECT x y width height color
LINE x1 y1 x2 y2 color
CIRCLE x y radius color
FILLCIRCLE x y radius color
The display is 128 × 128 pixels and uses the built-in 6 × 8 pixel font at size 1. Coordinates are absolute screen coordinates. App content belongs in y=19–113; TITLE owns y=0–18 and WAIT redraws the y=114–127 action bar. Drawing primitives are clipped by the graphics library at screen edges, but they are not automatically clipped away from the framework bars. Keep application graphics inside the body unless deliberately drawing the title.
| Instruction | Exact behavior |
|---|---|
CLEAR | Fills the complete framebuffer with the active theme background; Rainbow may be animated. Call it once per full-frame redraw to prevent trails. |
TITLE "text" | Draws the active title bar with back marker and battery icon. Keep the title short; firmware applies stable ellipsis before the battery area. |
TEXT x y color "text" | Draws left-aligned text. No wrapping, scrolling, or automatic ellipsis. |
TEXTC y color "text" | Centers text using six pixels per character. Long strings can begin off-screen; keep them to about 20 characters. |
NUM x y color value | Formats one signed integer and draws it left-aligned. |
RECT, FILLRECT | Outline or filled rectangle using x, y, width, height. |
LINE | Line from (x1,y1) to (x2,y2). |
CIRCLE, FILLCIRCLE | Outline or filled circle at center x/y with integer radius. |
Theme-aware colors are BG, BG2, PRIMARY, DIM, ACCENT, SELECT, and BORDER. Fixed colors are WHITE, BLACK, RED, GREEN, YELLOW, ORANGE, and CYAN. Prefer theme-aware colors for ordinary UI so the app remains legible across all 30 firmware themes; use fixed colors only when their meaning matters.
Recommended layout:
y 0..18 TITLE area (firmware style)
y 19..113 app body: text, sprites, controls
y 114..127 framework action bar drawn at WAIT
The firmware UI does not provide a DAS marquee. Use concise wording. Test long negative numbers, every selectable state, and both dark and colorful themes.
Graphical icons and sprites
Every app needs an icon. The scaffold writes assets/icon.sprite, but the manifest correctly says icon=assets/icon.dsi: the builder converts every .sprite source to a same-path .dsi resource inside the archive. Do not change the manifest to .sprite.
Sprite source is text-reviewable and compiled during packaging:
width=4
height=4
transparent=.
palette .=000000
palette W=FFFFFF
palette C=79E8FF
pixels:
....
.W..
....
....
---
....
.C..
CCC.
.C..
Sprite source rules:
widthandheightare required and each must be 1–128.palette X=RRGGBBmaps one character to one 24-bit source color. Every pixel character must be defined.transparent=Xis optional. Pixels using that palette character are skipped when drawn.pixels:starts pixel rows. Each frame must contain exactlyheightnon-empty rows and every row exactlywidthcharacters.---begins another frame; every frame uses the same dimensions/palette.- A source has at most 32 frames.
- Blank lines and
#comments are accepted beforepixels:; do not insert blank/comment rows inside pixel data. - Two sources cannot compile to the same case-insensitive output path, for example
ship.spritealongsideship.dsi.
An app icon is stricter: exactly 16 × 16 and one frame. Animation sprites may be 1–128 pixels and 1–32 frames. The compiler emits DSI1: a 16-byte little-endian header followed by frame-major RGB565 pixels. A DSI resource is limited to 192 KiB at runtime. A running app may load six sprites with a combined 512 KiB budget.
LOADSPR 0 "assets/player.dsi"
SPRITE 0 $0 $1 $2
SPRITESIZE 0 $10 $11 $12
UNLOADSPR 0
Instruction behavior:
LOADSPR slot "path"loads a normalized relative.dsipath from the installed app. Slot evaluates to 0–5. Loading replaces the old resource in that slot.SPRITE slot x y framedraws at native size; frame evaluates to0..frames-1. The operation does not scale or automatically animate.SPRITESIZE slot width_var height_var frames_varqueries metadata into three destination variables.UNLOADSPR slotreleases one resource. All resources are released on app exit or SD removal.
Load static resources once during an initialization path, not every frame. Repeated LOADSPR calls cause unnecessary SD reads and allocation churn. A common pattern is:
:init
LOADSPR 0 "assets/player.dsi"
SET $0 0
SET $1 50
GOTO frame
:frame
CLEAR
TITLE "Sprite Demo"
SPRITE 0 $0 $1 0
BUTTON RIGHT move_right
WAIT 50
GOTO frame
:move_right
ADD $0 2
GOTO frame
Invalid paths, corrupt resources, unloaded slots, invalid frames, or memory-budget violations stop the app safely.
Package format and builder checks
A .dtapp is a standard ZIP archive with a deliberately streamable profile:
- ZIP method 0 (
STORE) only; encryption and data descriptors are rejected. - Every entry is verified against ZIP CRC-32 while extracting.
- absolute paths, dot segments, backslashes, drive prefixes, control characters, and overlong paths are rejected.
- maximum 64 entries, 4 MB per entry, 8 MB expanded, and 10 MB archive size.
manifest.iniis at archive root.- archive paths are at most 96 characters; manifest entry/icon paths have the stricter 48-character limit.
manifest.iniis at most 1535 bytes.
CRC-32 detects corruption; it is not publisher authentication. UUIDs prevent identity/path collisions but do not prove authorship.
Build every reference app:
python tools/build_dtapp.py --all
Build selected projects or choose an output directory:
python tools/build_dtapp.py examples/apps/calendar examples/apps/orbit-runner
python tools/build_dtapp.py examples/apps/climate-deck -o release/apps
The builder validates manifest fields, permissions, UUIDs, normalized paths, script syntax/arity/labels, permission usage, referenced DSI resources, sprite dimensions, package budgets, duplicate UUIDs, deterministic metadata, ZIP flags, and all final CRCs.
It also assigns every archive entry a fixed timestamp and writes files in sorted order, so identical source produces byte-identical output. Output is first written as a temporary file, verified, and then replaces the requested .dtapp. Source files whose final filename begins with . are not application resources. Any ordinary extra source file is packaged and counts toward file/size limits, even if DAS never uses it.
Inspecting a built package on a computer
A .dtapp is intentionally readable by ordinary ZIP tools, but do not recompress or edit the built archive. To list it without changing it:
python -c "import zipfile; z=zipfile.ZipFile('dist/apps/field-notes.dtapp'); print(*[f'{i.filename} {i.file_size} bytes' for i in z.infolist()], sep='\n'); print('CRC:', z.testzip() or 'OK')"
Every compress_type should be 0/Stored. Rebuild from source after any edit.
Practical API recipes
Persistent counter
Declare permissions=storage, load once before the frame loop, and save only when the value changes:
:init
LOADKV $0 "count" 0
GOTO frame
:frame
CLEAR
TITLE "Counter"
TEXTC 45 PRIMARY "Saved count"
NUM 58 62 ACCENT $0
BUTTON SELECT increment
WAIT 80
GOTO frame
:increment
ADD $0 1
SAVEKV "count" $0
GOTO frame
Do not write the same value every frame. SD writes are slower than RAM changes and unnecessary writes increase wear.
Sensor dashboard with safe probes
Declare permissions=sensors. Probe availability before requesting a hardware-specific field:
SENSOR AHT_VALID $0
IF $0 == 0 no_aht
SENSOR TEMP $1
SENSOR HUMIDITY $2
GOTO draw
:no_aht
SET $1 0
SET $2 0
:draw
CLEAR
TITLE "Room Sensor"
NUM 20 45 ACCENT $1
NUM 20 62 PRIMARY $2
WAIT 500
GOTO draw
Temperature is centi-degrees and humidity is centi-percent. DAS has integer division, so DIV $1 100 produces whole degrees but discards the fraction. Preserve the original in another variable if both raw and whole values are needed.
Read captured Wi-Fi results
Declare permissions=wifi. The firmware—not the app—must have completed a scan:
WIFICOUNT $0
IF $0 <= 0 no_results
WIFIRSSI 0 $1
WIFICHANNEL 0 $2
WIFISECURITY 0 $3
GOTO show
:no_results
CLEAR
TITLE "WiFi Reader"
TEXTC 55 DIM "Run Scan WiFi first"
WAIT 500
GOTO no_results
:show
CLEAR
TITLE "Strongest AP"
NUM 20 42 ACCENT $1
NUM 20 58 PRIMARY $2
NUM 20 74 PRIMARY $3
WAIT 500
GOTO show
Frame animation
Use a frame variable and a reachable WAIT; do not repeatedly load the sprite:
:init
LOADSPR 0 "assets/anim.dsi"
SPRITESIZE 0 $10 $11 $12
SET $0 0
:frame
CLEAR
TITLE "Animation"
SPRITE 0 56 50 $0
ADD $0 1
MOD $0 $12
WAIT 100
GOTO frame
Validation and testing
Build validation
The normal builder is the first test and must pass without warnings/errors:
python tools/build_dtapp.py examples/apps/field-notes
Build all reference packages and run the SDK regression suite after changing the builder, runtime contract, or shared examples:
python tools/build_dtapp.py --all
python tools/test_phase3.py
The regression suite checks all reference manifests/packages, ZIP-STORE and CRC integrity, deterministic output, permission mismatch rejection, unknown service rejection, and missing-label rejection.
DAS stress runner
Run one project:
python tools/stress_das.py examples/apps/field-notes --frames 5000
Run every example for a longer release check:
python tools/stress_das.py --frames 10000
The stress runner simulates randomized button presses/holds and service values, checks instruction-budget yielding, exercises persistence/event operations, tracks loaded sprite metadata, and rejects out-of-range sprite slots or frame indices. It is not a pixel-perfect emulator and cannot prove hardware sensors, SD removal, actual radio state, theme contrast, or physical input behavior.
Device test checklist
- Install from a FAT32 SD card and launch through Apps.
- If using the grid menu, verify the app icon/short name in the correct page.
- Test first install and same-UUID update.
- Confirm private values survive exit, restart, and update when expected.
- Test Reset private data and confirmed Uninstall.
- Test every requested capability as Deny, Ask → Allow Once, Ask → Always Allow, and Deny Once.
- Remove the SD card while the app is running; firmware should stop it safely.
- Check all text/graphics within y=19–113 on the real 128 × 128 display.
- Test at least one ordinary theme and Rainbow; prefer theme-aware colors.
- Hold each direction and ensure repeat/
HELDbehavior is intentional. - Short BACK must exit; two-second BACK must open Power without app input leakage.
Troubleshooting
| Symptom/error | Cause and correction |
|---|---|
destination already exists from new_dtapp.py | The scaffold never overwrites a project. Choose another slug or deliberately edit the existing directory. |
slug must contain... | Use lowercase a-z, digits, and single hyphens, for example field-notes. |
manifest.ini is missing | Run the builder on the app project directory, not main.das or its parent collection. |
| Missing API 3 field | Add category, description, and permissions along with all common manifest fields. |
| UUID is invalid/noncanonical | Generate a lowercase UUID4 once. Do not add braces or uppercase letters. |
| Duplicate UUID | Two projects passed to one build claim the same identity. Assign a new UUID only to the genuinely different app. |
| Entry/icon is missing | Manifest path must match the packaged case-sensitive relative output. A .sprite source is referenced as .dsi. |
| Icon must be exactly 16×16 | Set icon source to width 16, height 16, one frame; use other files for larger/animated sprites. |
| Sprite row/frame error | Every frame needs exactly height rows and each row exactly width palette characters. |
| Unknown/invalid instruction or wrong arguments | Check the exact syntax in this guide. The language has no optional arguments or inline comments. |
| Missing jump target / duplicate label | Define every label once; names are case-insensitive and at most 24 characters. |
Script has no WAIT/END | Add a reachable frame yield or a clean exit. |
Instruction budget exceeded on device | A runtime path processed more than 640 source lines without reaching WAIT/END, commonly a tight GOTO/HELD loop. Blank/comment/label lines count too. |
| Script line exceeds 159 bytes | Shorten the line/text or split drawing across lines. Count UTF-8 bytes, not only visible characters. |
| Operation requires manifest permission |
When the device shows an App Error, note both the message and at byte N. The byte position is not a source line number, but it narrows the failing region. Reproduce with the official builder first; device-side validation remains independent for safety.
Design and release best practices
- Start from
new_dtapp.pyor the closest reference app; do not handcraft archives. - Keep one permanent UUID per app across every update.
- Increment
versionbefore distributing a changed package. - Request the smallest permission set. Never request reserved
bluetooth/fileswithout an actual future API operation. - Put initialization and
LOADSPRbefore the frame loop. - Keep normal input handlers short and return to a frame containing
WAIT. - Write private data only after meaningful changes, not every frame.
- Probe hardware availability and handle zero Wi-Fi results.
- Keep body text concise, ASCII, and within about 20 characters per line.
- Use theme colors for normal UI and fixed colors for semantic warnings/status.
- Treat service values honestly: local sensors are not network forecasts; RSSI is not distance; Wi-Fi security code is advertised configuration, not proof of safety.
- Do not attempt raw GPIO, arbitrary files, credentials, disruptive Wi-Fi actions, native code, or memory access. DAS intentionally does not expose them.
- Preserve editable source in version control. Distribute the built
.dtapp, not.sprite/.dasdirectories. - For repository releases, copy validated archives to both
dist/appsandREADY_TO_COPY_TO_SD, then reruntools/test_phase3.py.
Reference applications
The repository currently contains 54 complete API 3 projects. The original reference set below remains useful for focused examples; Phase 3.13 adds 20 modern apps and 20 games generated from reviewed, deterministic templates.
calendar: date arithmetic, month navigation, storage-backed daily event types.calculator: selectable grid, two-operand integer entry, state, and branching.brick-breaker: full script-driven paddle game without special permissions.meteor-dodge: animated resources and persistent high score.orbit-runner: multi-sprite game with private storage.climate-deck: safe sensor probes plus system-information services.neon-catch: animated sprite action and persistent high score.dice-forge: six-frame animation and persistent roll statistics.wifi-lens: permission-gated visualization of captured Wi-Fi results.lunar-lander: sprite physics and persistent best-fuel score.focus-timer: time-based UI loop and persistent completed-session count.tally-board: three selectable persistent counters.reaction-rush: randomized timing and persistent reflex score.laser-dodge: animated obstacle game and persistent high score.
Phase 3.13 app examples:
- wellness/productivity:
breath-studio,hydration-bloom,habit-galaxy,mood-mosaic,interval-coach,tea-timer,focus-counter; - tools/visualization:
pocket-metronome,decision-prism,color-lab,temperature-orbit,dewpoint-garden,pressure-atlas,memory-pulse,storage-orbit,daylight-grid,today-marker,signal-palette,channel-scout,zen-orbit; - games:
comet-catcher,reef-rescue,meteor-shield,toxic-rain,pixel-racer,tunnel-sprint,reef-runner,nova-lanes,reactor-tap,pulse-lock,stack-zone,quantum-stop,circuit-match,signal-simon,neon-directions,vector-reflex,gravity-flip,orbital-switch,star-defender,meteor-miner.
tools/generate_showcase_apps.py is the reproducible source generator for
those 40 projects. It only replaces directories bearing its
.showcase-generated marker and refuses to overwrite an unrelated project.
Edit the generator when applying a shared template correction, regenerate,
then review, rebuild, and stress-test all projects.
Readable sources are under examples/apps; ready-to-transfer archives are under dist/apps and READY_TO_COPY_TO_SD.
Good starting points for a newcomer:
- simplest no-permission UI/control flow:
calculatororbrick-breaker; - private storage:
tally-boardorfocus-timer; - sprites/animation:
dice-forgeormeteor-dodge; - sensors/system permissions:
climate-deck; - Wi-Fi permission and zero-result handling:
wifi-lens; - date/event storage:
calendar.
API 2 migration
Keep the existing RFC-4122 UUID, change api=3, and add category, description, and permissions. Use permissions=none unless the script invokes an API 3 service. Rebuild and install the archive; the matching UUID performs an update. API 1 directory packages and short textual IDs are unsupported and should be assigned a new permanent UUID before migration.
Before distributing a migrated package, run the builder and stress runner, test its permission prompts, verify private data survives a same-UUID update, and increment the visible version. API 2 remains install-compatible, but new development should use API 3.
Final packaging checklist
- Project builds with
tools/build_dtapp.py. - Manifest uses API 3, concise metadata, minimum permissions, and the permanent UUID.
- Version was incremented for this release.
- Icon is one 16 × 16 frame and is legible in several themes.
- Every runtime path reaches
WAITorENDwithin 640 processed lines. - Sprites load once, fit memory limits, and use valid frame indices.
- Persistent writes occur only on changes and stay within quota.
- Missing sensors, zero Wi-Fi results, denied permissions, and SD removal are handled honestly.
- Host stress test passes for an appropriate frame count.
- First install and same-UUID update pass on the physical device.
- Text and graphics fit the 128 × 128 body without covering title/action bars.
- Only the final
.dtappis copied/distributed; editable sources remain separate.