diff --git a/CLAUDE.md b/CLAUDE.md
index 201d0dd1a3..575b6e7f98 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,7 +57,7 @@ Task-specific instructions are split into skill files under `skills/`. You MUST
| `skills/internal/documentation_rst.md` | Editing RST in `doc/source/`, `//!` doc-comments in `daslib/*.das`, tutorial RST pages |
| `skills/internal/tutorials.md` | Anything that looks like a tutorial - they live under `/tutorials//`, NEVER `modules//tutorial/` |
| `skills/internal/tutorial_prose.md` | WRITING or revising general-reader doc/tutorial prose (`documentation_rst.md` is mechanics, this is the words) |
-| `skills/cpp_integration.md` | Embedding daslang in C++; binding types/functions/enums |
+| `skills/cpp_integration.md` | Embedding daslang in C++; binding types/functions/enums; shipping without the compiler (`libDaScriptNano`) |
| `skills/internal/cpp_codebase_notes.md` | Working on daslang's own C++ - where inference/builtins/errors/parser live, AST function flags |
| `skills/internal/clang_bind_build.md` | Enabling `dasClangBind` / bumping the libclang SDK / running any `bind_*.das` self-binder |
| `skills/daslib_modules.md` | Working with `daslib/` modules or extending the stdlib |
@@ -258,6 +258,7 @@ Most layout is obvious from `ls`. The non-obvious ones:
- `skills/daslang/` - the **distributable, SDK-free daslang language skill** for third-party AI agents (`SKILL.md` + `references/`), NOT a repo task skill. Every example is probe-verified; its `README.md` carries the editing rules. Grammar/stdlib/default changes update it in the same arc
- `daslib/aot_cpp.das` - the AOT C++ emitter lives here, NOT in C++
+- `nano/` - `libDaScriptNano`, the runtime with no compiler in it: a shadow include root whose four headers win over `include/`, so thirteen `src/` sources compile against a minimal `Context` unmodified. A source that needs an edit to build there is CARVED upstream, never forked (`nano/ARCHITECTURE.md`); every directory linking it must clear its inherited include dirs, which `nano/REVIEW.das` checks
- `tests/aot/CMakeLists.txt` - register new test directories here for AOT compilation. Two AOT binaries: `test_aot_subset` (tests/language only, in ALL - the per-PR CI compile gate) and full `test_aot` (`EXCLUDE_FROM_ALL`, ~1080 AOT TUs - nightly CI + `preflight --full` only, via `--target test_aot`/`run_tests_aot`)
- `dastest/` - test framework (used by both `tests/` and external repos)
- `utils/detect-dupe/` (in-repo dupe finder) and `utils/find-dupe/` (Claude judge; needs `daspkg install --root utils/find-dupe` + `ANTHROPIC_API_KEY`) - both also MCP tools
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0024941ddd..709f32e11f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -934,8 +934,10 @@ include/vecmath/dag_vecMath.h
include/vecmath/dag_vecMathDecl.h
include/vecmath/dag_vecMath_common.h
include/vecmath/dag_vecMath_const.h
+include/vecmath/dag_vecMath_double.h
include/vecmath/dag_vecMath_neon.h
include/vecmath/dag_vecMath_pc_sse.h
+include/vecmath/dag_vecMath_scalar.h
include/vecmath/dag_vecMath_trig.h
)
list(SORT VECMATH_SRC)
@@ -1038,6 +1040,7 @@ src/misc/env_cfg.cpp
src/misc/das_common.cpp
src/misc/gc_node.cpp
src/misc/globals.cpp
+src/misc/hal.cpp
src/misc/sysos.cpp
src/misc/string_writer.cpp
src/misc/memory_model.cpp
@@ -1107,6 +1110,7 @@ src/misc/network.cpp
src/simulate/hash.cpp
src/simulate/debug_info.cpp
src/simulate/runtime_string.cpp
+src/simulate/escape_string.cpp
src/simulate/runtime_iterator.cpp
src/simulate/runtime_array.cpp
src/simulate/runtime_table.cpp
@@ -1116,6 +1120,8 @@ src/simulate/simulate.cpp
src/simulate/simulate_exceptions.cpp
src/simulate/simulate_gc.cpp
src/simulate/simulate_gc_pod.cpp
+src/simulate/builtin_array_ops.cpp
+src/simulate/builtin_runtime_ops.cpp
src/simulate/aot_library.cpp
src/simulate/simulate_tracking.cpp
src/simulate/simulate_visit.cpp
@@ -1124,6 +1130,7 @@ src/simulate/simulate_fn_hash.cpp
src/simulate/simulate_instrument.cpp
include/daScript/simulate/cast.h
include/daScript/simulate/annotation_arguments.h
+src/simulate/annotation_arguments.cpp
include/daScript/simulate/code_of_policies.h
include/daScript/simulate/hash.h
include/daScript/simulate/heap.h
@@ -1229,6 +1236,11 @@ list(SORT DAS_LIB_SRC)
SOURCE_GROUP_FILES("daslib" DSA_LIB_SRC)
list(SORT DAS_LIB_SRC)
+# libDaScriptNano - the minimal runtime. Added BEFORE the include_directories
+# below on purpose: nano picks its own header search order, and inheriting this
+# directory's would put the full include/ ahead of the headers nano shadows.
+add_subdirectory(nano)
+
include_directories(include)
include_directories(3rdparty/fmt/include)
@@ -1615,6 +1627,10 @@ if (NOT ${DAS_TOOLS_DISABLED})
# Build daslang utilities as standalone executables (-exe mode)
add_subdirectory(utils)
+ # Standalone-AOT examples on libDaScriptNano. They need daslang to generate
+ # their C++, which is why they live here and not beside the nano library.
+ add_subdirectory(examples/standalone)
+
endif()
# This list should be significantly reduced, most of the files, except aot related should be private.
@@ -1796,6 +1812,46 @@ install(FILES ${DAS_BUILTIN_HEADERS}
DESTINATION include/daScript/builtin
)
+# libDaScriptNano ships as sources, not as a built library: an embedder cross-
+# compiles it for their own target with their own flags, which is the whole
+# point of it. Its CMakeLists is self-contained given DASLANG_NANO_ROOT.
+install(DIRECTORY ${PROJECT_SOURCE_DIR}/nano/
+ DESTINATION nano
+ FILES_MATCHING
+ PATTERN "*.h"
+ PATTERN "*.cpp"
+ PATTERN "*.md"
+ PATTERN "CMakeLists.txt"
+ # REVIEW.md reviews a change to this repo's copy of nano; an SDK carries
+ # the library, not the process of changing it. (REVIEW.das is not matched
+ # by any pattern above, so it never ships either.)
+ REGEX "/REVIEW\\.md$" EXCLUDE
+)
+# The shared runtime sources nano compiles. They live under src/, which the SDK
+# otherwise does not carry; nano/CMakeLists.txt names them one by one, and
+# nano/REVIEW.das checks that this list still covers that one.
+install(FILES
+ ${PROJECT_SOURCE_DIR}/src/misc/hal.cpp
+ ${PROJECT_SOURCE_DIR}/src/misc/memory_model.cpp
+ DESTINATION src/misc
+)
+install(FILES
+ ${PROJECT_SOURCE_DIR}/src/simulate/annotation_arguments.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/aot_library.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/builtin_array_ops.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/builtin_runtime_ops.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/data_walker.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/debug_info.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/escape_string.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/heap.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/runtime_array.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/runtime_iterator.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/runtime_table.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/simulate_gc_pod.cpp
+ ${PROJECT_SOURCE_DIR}/src/simulate/standalone_ctx_utils.cpp
+ DESTINATION src/simulate
+)
+
# Install all modules and main library.
install(TARGETS
libDaScript libDaScript_runtime libDaScriptDyn libDaScriptDyn_runtime libUriParser libUriParserDyn
@@ -2094,6 +2150,11 @@ install(DIRECTORY ${PROJECT_SOURCE_DIR}/examples/
DESTINATION ${DAS_INSTALL_EXAMPLESDIR}
PATTERN "fatman" EXCLUDE
PATTERN "modules" EXCLUDE
+ # standalone/ builds against a daslang TARGET in-tree and against an
+ # installed SDK from a bundle; the SDK copy is the .standalone.cmake below,
+ # renamed into place after this excludes the in-tree one.
+ PATTERN "standalone/CMakeLists.txt" EXCLUDE
+ PATTERN "CMakeLists.standalone.cmake" EXCLUDE
PATTERN "_build" EXCLUDE
PATTERN ".daspkg_cache" EXCLUDE
PATTERN ".daspkg_tmp" EXCLUDE
@@ -2109,6 +2170,13 @@ install(DIRECTORY ${PROJECT_SOURCE_DIR}/examples/
PATTERN "*.sf2" EXCLUDE
)
+# The SDK's copy of the standalone examples' build: same four targets, driven by
+# find_package(DAS) instead of in-tree targets.
+install(FILES ${PROJECT_SOURCE_DIR}/examples/standalone/CMakeLists.standalone.cmake
+ DESTINATION ${DAS_INSTALL_EXAMPLESDIR}/standalone
+ RENAME CMakeLists.txt
+)
+
# The Fox rig/animation is CC-BY 4.0, so its attribution must also ship at the
# bundle root beside the other licenses (the examples/gltf copy rides the blanket).
install(FILES ${PROJECT_SOURCE_DIR}/examples/gltf/GLTF_SAMPLE_ASSETS.LICENSE
diff --git a/ci/nano_arm_build.sh b/ci/nano_arm_build.sh
new file mode 100755
index 0000000000..3b38d3f245
--- /dev/null
+++ b/ci/nano_arm_build.sh
@@ -0,0 +1,74 @@
+#!/usr/bin/env bash
+# Cross-compile libDaScriptNano and the tier-A standalone example for a
+# cortex-m4, and print the size of what comes out.
+#
+# THIS DOES NOT PASS YET, and it is not wired into CI. It is the acceptance test
+# for the freestanding port, and running it names the work: newlib has no
+# posix_memalign / malloc_usable_size / madvise, its libstdc++ is built without
+# threads so declares nothing, its uint32_t is `unsigned long` (which
+# makes every BitfieldAny and vec4 conversion ambiguous), and alloca
+# needs its own include. Those live in platform.h, smart_ptr.h, arraytype.h and
+# vectypes.h - shared headers every platform compiles, which is why the port is
+# its own change rather than a corner of nano.
+#
+# Once it is green it becomes nano's drift tripwire: a change that pulls the
+# compiler, fmt or the host's I/O back into the runtime fails here first.
+#
+# Usage: ci/nano_arm_build.sh [out-dir]
+# Toolchain: apt-get install gcc-arm-none-eabi
+set -euo pipefail
+
+DASLANG="${1:?usage: ci/nano_arm_build.sh [out-dir]}"
+OUT="${2:-build/nano-arm}"
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+CXX="${ARM_CXX:-arm-none-eabi-g++}"
+SIZE="${ARM_SIZE:-arm-none-eabi-size}"
+
+cd "$ROOT"
+mkdir -p "$OUT/generated"
+
+# nano/CMakeLists.txt is the one source list. Reading it here keeps this script
+# from becoming a second one that drifts. The two variables it writes paths
+# against mean different roots: nano's own sources sit under nano/, the shared
+# ones under the repo root.
+sources() {
+ sed -n '/^set(NANO_OWN_SRC/,/^)/p;/^set(NANO_SHARED_SRC/,/^)/p' nano/CMakeLists.txt \
+ | grep -o '\${[A-Z_]*}/[^ ]*\.cpp' \
+ | sed -e 's|^\${CMAKE_CURRENT_SOURCE_DIR}/|nano/|' \
+ -e 's|^\${DASLANG_NANO_ROOT}/||'
+}
+
+echo "== generating standalone C++ for examples/standalone/01_pure"
+"$DASLANG" utils/aot/main.das -- -ctx \
+ examples/standalone/01_pure/pure_math.das "$OUT/generated/"
+
+# -Os and no exceptions/RTTI is what an embedded target actually builds with;
+# nano's das_config.h already forces DAS_ENABLE_EXCEPTIONS off, and this proves
+# the compiler agrees.
+ARM_FLAGS=(
+ -mcpu=cortex-m4 -mthumb -mfloat-abi=soft
+ -Os -ffunction-sections -fdata-sections
+ -fno-exceptions -fno-rtti -fno-threadsafe-statics
+ -std=c++17
+ -I nano/include -I include -I "$OUT/generated"
+)
+
+OBJS=()
+echo "== compiling nano"
+for src in $(sources); do
+ obj="$OUT/$(echo "$src" | tr '/' '_').o"
+ "$CXX" "${ARM_FLAGS[@]}" -c "$src" -o "$obj"
+ OBJS+=("$obj")
+done
+
+echo "== compiling the generated context and the example"
+"$CXX" "${ARM_FLAGS[@]}" -c "$OUT/generated/pure_math.das.cpp" -o "$OUT/pure_math.o"
+"$CXX" "${ARM_FLAGS[@]}" -c examples/standalone/01_pure/main.cpp -o "$OUT/main.o"
+OBJS+=("$OUT/pure_math.o" "$OUT/main.o")
+
+echo "== linking"
+"$CXX" "${ARM_FLAGS[@]}" --specs=nosys.specs -Wl,--gc-sections \
+ "${OBJS[@]}" -o "$OUT/nano_01_pure.elf"
+
+echo "== size (cortex-m4, -Os, gc-sections)"
+"$SIZE" "$OUT/nano_01_pure.elf"
diff --git a/examples/standalone/01_pure/main.cpp b/examples/standalone/01_pure/main.cpp
new file mode 100644
index 0000000000..e39e6e3e79
--- /dev/null
+++ b/examples/standalone/01_pure/main.cpp
@@ -0,0 +1,59 @@
+// Tier A: a C++ program whose whole daslang dependency is libDaScriptNano.
+//
+// There is no daScript.h here, no Module, no compiler - just the header the
+// standalone emitter generated. The context is a plain C++ object: construct
+// it, call its methods, let it go out of scope.
+
+#include "daScript/nano_print.h"
+#include "pure_math.das.h"
+
+#include
+
+using namespace das;
+
+static int failures = 0;
+
+static void expect_int ( const char * what, int have, int want ) {
+ if ( have != want ) {
+ printf("%s = %d, expected %d\n", what, have, want);
+ failures ++;
+ }
+}
+
+static void expect_float ( const char * what, float have, float want ) {
+ const float d = have > want ? have - want : want - have;
+ if ( d > 1e-5f ) {
+ printf("%s = %f, expected %f\n", what, double(have), double(want));
+ failures ++;
+ }
+}
+
+static void to_console ( const char * text ) {
+ fputs(text, stdout);
+}
+
+int main () {
+ // On a board this is where printk or a UART write goes. Setting it before
+ // the context exists means even a panic during construction is visible.
+ das_nano_set_print(&to_console);
+
+ pure_math::Standalone ctx;
+
+ pure_math::Vec3 a; a.x = 1.0f; a.y = 2.0f; a.z = 3.0f;
+ pure_math::Vec3 b; b.x = 4.0f; b.y = 5.0f; b.z = 6.0f;
+
+ expect_float("dot(a,b)", ctx.dot(a, b), 32.0f);
+
+ pure_math::Vec3 s = ctx.scale(a, 2.0f);
+ expect_float("scale(a,2).x", s.x, 2.0f);
+ expect_float("scale(a,2).z", s.z, 6.0f);
+
+ expect_float("component(a,y)", ctx.component(a, pure_math::Axis::y), 2.0f);
+ expect_float("weighted_sum(a)", ctx.weighted_sum(a), 1.0f*0.25f + 2.0f*0.5f + 3.0f*0.25f);
+
+ expect_int("collatz_steps(27)", ctx.collatz_steps(27), 111);
+ expect_int("collatz_steps(1)", ctx.collatz_steps(1), 0);
+
+ printf(failures ? "01_pure: %d failure(s)\n" : "01_pure: ok\n", failures);
+ return failures ? 1 : 0;
+}
diff --git a/examples/standalone/01_pure/pure_math.das b/examples/standalone/01_pure/pure_math.das
new file mode 100644
index 0000000000..2eeef286b0
--- /dev/null
+++ b/examples/standalone/01_pure/pure_math.das
@@ -0,0 +1,65 @@
+options gen2
+options stack = 4096
+
+// Tier A: pure POD compute. No das heap is ever touched here - no arrays, no
+// tables, no strings, no closures - so the generated context needs only the
+// stack and the constant data the standalone emitter puts in globals.
+//
+// `options stack = 4096` is honored exactly: the standalone context reserves
+// this many bytes plus the headroom the global initializers need.
+
+struct Vec3 {
+ x : float
+ y : float
+ z : float
+}
+
+enum Axis {
+ x
+ y
+ z
+}
+
+let TAPS = fixed_array(0.25, 0.5, 0.25)
+
+[export]
+def dot(a, b : Vec3) : float {
+ return a.x * b.x + a.y * b.y + a.z * b.z
+}
+
+[export]
+def scale(v : Vec3; k : float) : Vec3 {
+ return Vec3(x = v.x * k, y = v.y * k, z = v.z * k)
+}
+
+[export]
+def component(v : Vec3; axis : Axis) : float {
+ if (axis == Axis.x) {
+ return v.x
+ } elif (axis == Axis.y) {
+ return v.y
+ }
+ return v.z
+}
+
+// A dim (fixed-size array) lives in globals or on the stack, never on the heap.
+[export]
+def weighted_sum(v : Vec3) : float {
+ let parts = fixed_array(v.x, v.y, v.z)
+ var total = 0.0
+ for (p, w in parts, TAPS) {
+ total += p * w
+ }
+ return total
+}
+
+[export]
+def collatz_steps(n : int) : int {
+ var steps = 0
+ var value = n
+ while (value > 1) {
+ value = value % 2 == 0 ? value / 2 : value * 3 + 1
+ steps++
+ }
+ return steps
+}
diff --git a/examples/standalone/02_heap/heap_demo.das b/examples/standalone/02_heap/heap_demo.das
new file mode 100644
index 0000000000..084a80170b
--- /dev/null
+++ b/examples/standalone/02_heap/heap_demo.das
@@ -0,0 +1,58 @@
+options gen2
+options stack = 16384
+options heap_size_hint = 65536
+
+// Tier B: the das heap. Arrays, tables and `new`/`delete` all allocate from the
+// context's own heap, sized by `options heap_size_hint` - a single malloc on a
+// host, and on a board the one allocation the runtime makes.
+//
+// Nothing here uses strings: string interpolation and the string builders are
+// the next tier up, and nano leaves them out on purpose.
+
+struct Sample {
+ id : int
+ value : float
+}
+
+[export]
+def sum_range(n : int) : int {
+ var values : array
+ values |> reserve(n)
+ for (i in range(n)) {
+ values |> push(i * i)
+ }
+ var total = 0
+ for (v in values) {
+ total += v
+ }
+ return total
+}
+
+[export]
+def histogram_peak(n : int) : int {
+ var counts : table
+ for (i in range(n)) {
+ let bucket = i % 7
+ counts[bucket] = (counts?[bucket] ?? 0) + 1
+ }
+ var peak = 0
+ for (c in values(counts)) {
+ if (c > peak) {
+ peak = c
+ }
+ }
+ return peak
+}
+
+// An explicit `delete` frees through the scope-free path, the only part of the
+// collector nano carries. There is no GC pass behind it: what a script does not
+// delete stays allocated until the context does.
+[export]
+def alloc_and_free(id : int) : float {
+ var sample = new Sample(id = id, value = float(id) * 0.5)
+ let v = sample.value
+ unsafe {
+ delete sample
+ }
+ return v
+}
diff --git a/examples/standalone/02_heap/main.cpp b/examples/standalone/02_heap/main.cpp
new file mode 100644
index 0000000000..12821522a5
--- /dev/null
+++ b/examples/standalone/02_heap/main.cpp
@@ -0,0 +1,50 @@
+// Tier B: the same nano runtime, now with the das heap in play.
+//
+// `options heap_size_hint = 65536` in heap_demo.das is what the context asks
+// its allocator for up front. Everything the script allocates - the array, the
+// table, the `new Sample` - comes out of that.
+
+#include "daScript/nano_print.h"
+#include "heap_demo.das.h"
+
+#include
+
+using namespace das;
+
+static int failures = 0;
+
+static void expect_int ( const char * what, int have, int want ) {
+ if ( have != want ) {
+ printf("%s = %d, expected %d\n", what, have, want);
+ failures ++;
+ }
+}
+
+static void to_console ( const char * text ) {
+ fputs(text, stdout);
+}
+
+int main () {
+ das_nano_set_print(&to_console);
+
+ heap_demo::Standalone ctx;
+
+ // 0..9 squared
+ expect_int("sum_range(10)", ctx.sum_range(10), 285);
+ expect_int("sum_range(0)", ctx.sum_range(0), 0);
+
+ // 20 values over 7 buckets: buckets 0..5 get 3, bucket 6 gets 2
+ expect_int("histogram_peak(20)", ctx.histogram_peak(20), 3);
+
+ expect_int("alloc_and_free(8)", int(ctx.alloc_and_free(8)), 4);
+
+ // The heap is reused, not grown: running the same work again must not
+ // depend on how much ran before it.
+ for ( int i = 0; i != 100; ++i ) {
+ ctx.alloc_and_free(i);
+ }
+ expect_int("sum_range(10) after 100 alloc/free", ctx.sum_range(10), 285);
+
+ printf(failures ? "02_heap: %d failure(s)\n" : "02_heap: ok\n", failures);
+ return failures ? 1 : 0;
+}
diff --git a/examples/standalone/03_closures/closures.das b/examples/standalone/03_closures/closures.das
new file mode 100644
index 0000000000..addc82fe14
--- /dev/null
+++ b/examples/standalone/03_closures/closures.das
@@ -0,0 +1,57 @@
+options gen2
+options stack = 32768
+options heap_size_hint = 65536
+
+// Tier C: the shapes that need the function table at runtime. A lambda, a
+// function pointer and a generator are all values that name a function by its
+// mangled-name hash and look it up when invoked, so this is what proves the
+// lookup tables the generated constructor fills are real on nano.
+
+[export]
+def apply_twice(x : int) : int {
+ let add3 = @(v : int) : int => v + 3
+ return invoke(add3, invoke(add3, x))
+}
+
+def double_it(x : int) : int {
+ return x + x
+}
+
+[export]
+def call_through_pointer(x : int) : int {
+ let fp = @@double_it
+ return invoke(fp, x)
+}
+
+// A generator is a lambda with a resume point: each `yield` returns and the
+// next call continues where it left off. The capture frame lives on the heap.
+[export]
+def sum_squares(n : int) : int {
+ var gen <- generator {
+ for (i in range(n)) {
+ yield i * i
+ }
+ return false
+ }
+ var total = 0
+ for (v in gen) {
+ total += v
+ }
+ return total
+}
+
+// A capturing closure. The capture frame is a heap allocation with a finalizer,
+// so this is the shape that proves nano frees lambdas as well as calls them.
+[export]
+def count_up_to(limit : int) : int {
+ var count = 0
+ unsafe {
+ let step <- @ capture(& count) {
+ count++
+ }
+ while (count < limit) {
+ invoke(step)
+ }
+ }
+ return count
+}
diff --git a/examples/standalone/03_closures/main.cpp b/examples/standalone/03_closures/main.cpp
new file mode 100644
index 0000000000..0245079941
--- /dev/null
+++ b/examples/standalone/03_closures/main.cpp
@@ -0,0 +1,47 @@
+// Tier C: lambdas, function pointers and generators on nano.
+//
+// Each of these looks a function up at runtime, through the tables the
+// generated constructor fills in. Nothing about that path is compiled away.
+
+#include "daScript/nano_print.h"
+#include "closures.das.h"
+
+#include
+
+using namespace das;
+
+static int failures = 0;
+
+static void expect_int ( const char * what, int have, int want ) {
+ if ( have != want ) {
+ printf("%s = %d, expected %d\n", what, have, want);
+ failures ++;
+ }
+}
+
+static void to_console ( const char * text ) {
+ fputs(text, stdout);
+}
+
+int main () {
+ das_nano_set_print(&to_console);
+
+ closures::Standalone ctx;
+
+ expect_int("apply_twice(10)", ctx.apply_twice(10), 16);
+ expect_int("call_through_pointer(21)", ctx.call_through_pointer(21), 42);
+ expect_int("sum_squares(5)", ctx.sum_squares(5), 30);
+ expect_int("sum_squares(0)", ctx.sum_squares(0), 0);
+ expect_int("count_up_to(7)", ctx.count_up_to(7), 7);
+
+ // Capture frames and generator state are heap allocations with finalizers.
+ // Repeating the work must not leave any of them behind.
+ for ( int i = 0; i != 200; ++i ) {
+ ctx.sum_squares(5);
+ ctx.count_up_to(3);
+ }
+ expect_int("sum_squares(5) after 200 rounds", ctx.sum_squares(5), 30);
+
+ printf(failures ? "03_closures: %d failure(s)\n" : "03_closures: ok\n", failures);
+ return failures ? 1 : 0;
+}
diff --git a/examples/standalone/04_c_binding/blinker.das b/examples/standalone/04_c_binding/blinker.das
new file mode 100644
index 0000000000..cb702de56b
--- /dev/null
+++ b/examples/standalone/04_c_binding/blinker.das
@@ -0,0 +1,26 @@
+options gen2
+options stack = 16384
+
+// Tier A + output. This is the shape an embedded program actually has: C owns
+// the hardware and the loop, daslang owns the decision. Each tick the C side
+// asks for a lamp pattern and writes whatever comes back to its GPIO register.
+//
+// `print` is the one call here that leaves the runtime. On a host it reaches
+// stdout; on a board it reaches whatever das_nano_set_print was pointed at.
+// Nothing else in nano does I/O.
+
+let LAMP_COUNT = 4
+
+// A Larson scanner: one lit lamp bouncing between the ends.
+[export]
+def lamp_pattern(tick : int) : int {
+ let period = (LAMP_COUNT - 1) * 2
+ let phase = tick % period
+ let position = phase < LAMP_COUNT ? phase : period - phase
+ return 1 << position
+}
+
+[export]
+def announce(tick, pattern : int) {
+ print("tick {tick}: lamps {pattern}\n")
+}
diff --git a/examples/standalone/04_c_binding/main.cpp b/examples/standalone/04_c_binding/main.cpp
new file mode 100644
index 0000000000..f4cf59e96e
--- /dev/null
+++ b/examples/standalone/04_c_binding/main.cpp
@@ -0,0 +1,48 @@
+// C owns the hardware, daslang owns the decision.
+//
+// This is the tier a microcontroller program lives in. The "GPIO register" here
+// is a variable, but nothing else is pretend: the loop is C, the pattern comes
+// from a compiled daslang function, and everything the script prints leaves
+// through the one sink nano asks the embedder for.
+
+#include "daScript/nano_print.h"
+#include "blinker.das.h"
+
+#include
+
+using namespace das;
+
+// Stand-in for a memory-mapped output register.
+static unsigned g_gpio_out = 0;
+
+static void gpio_write ( unsigned bits ) {
+ g_gpio_out = bits;
+}
+
+// On a board this is printk, HAL_UART_Transmit, or an append to a ring buffer.
+static void board_print ( const char * text ) {
+ fputs(text, stdout);
+}
+
+int main () {
+ das_nano_set_print(&board_print);
+
+ blinker::Standalone ctx;
+
+ // One full sweep of the scanner: 1,2,4,8,4,2 then back to 1.
+ static const unsigned expected[] = { 1, 2, 4, 8, 4, 2, 1, 2 };
+ int failures = 0;
+
+ for ( int tick = 0; tick != 8; ++tick ) {
+ const unsigned pattern = unsigned(ctx.lamp_pattern(tick));
+ gpio_write(pattern);
+ ctx.announce(tick, int(pattern));
+ if ( g_gpio_out != expected[tick] ) {
+ printf("tick %d wrote %u, expected %u\n", tick, g_gpio_out, expected[tick]);
+ failures ++;
+ }
+ }
+
+ printf(failures ? "04_c_binding: %d failure(s)\n" : "04_c_binding: ok\n", failures);
+ return failures ? 1 : 0;
+}
diff --git a/examples/standalone/CMakeLists.standalone.cmake b/examples/standalone/CMakeLists.standalone.cmake
new file mode 100644
index 0000000000..f904e3f58a
--- /dev/null
+++ b/examples/standalone/CMakeLists.standalone.cmake
@@ -0,0 +1,52 @@
+###########################################################
+# Standalone-AOT examples on libDaScriptNano, built against
+# an installed daslang SDK.
+#
+# cmake -DCMAKE_PREFIX_PATH=
+# cmake --build . --config Release
+#
+# Each example compiles its .das to C++ with the SDK's daslang, then links
+# that C++ and nano - a program with no compiler in it and no daslang binary
+# at run time.
+###########################################################
+
+cmake_minimum_required(VERSION 3.16)
+project(daslang_standalone_examples CXX)
+
+find_package(DAS REQUIRED)
+
+# The SDK root, two levels up from lib/cmake/DAS/.
+get_filename_component(DAS_SDK_ROOT "${DAS_DIR}/../../.." ABSOLUTE)
+message(STATUS "daslang SDK root: ${DAS_SDK_ROOT}")
+
+# nano ships as sources; build it here with this project's flags.
+add_subdirectory("${DAS_SDK_ROOT}/nano" nano_build)
+
+# nano decides the header search order for everything that links it, and a
+# directory-level include_directories() is searched BEFORE any target's own.
+set_property(DIRECTORY PROPERTY INCLUDE_DIRECTORIES "")
+
+set(NANO_EXAMPLE_GEN "${CMAKE_CURRENT_BINARY_DIR}/_standalone_ctx_generated")
+file(MAKE_DIRECTORY "${NANO_EXAMPLE_GEN}")
+
+function(das_nano_example name dir das_file)
+ set(_gen_cpp "${NANO_EXAMPLE_GEN}/${das_file}.cpp")
+ add_custom_command(
+ OUTPUT "${_gen_cpp}" "${NANO_EXAMPLE_GEN}/${das_file}.h"
+ COMMAND $
+ "${DAS_SDK_ROOT}/utils/aot/main.das"
+ -- -ctx "${CMAKE_CURRENT_SOURCE_DIR}/${dir}/${das_file}"
+ "${NANO_EXAMPLE_GEN}/"
+ DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/${dir}/${das_file}"
+ COMMENT "Standalone AOT: ${das_file}"
+ VERBATIM
+ )
+ add_executable(${name} "${CMAKE_CURRENT_SOURCE_DIR}/${dir}/main.cpp" "${_gen_cpp}")
+ target_include_directories(${name} PRIVATE "${NANO_EXAMPLE_GEN}")
+ target_link_libraries(${name} PRIVATE libDaScriptNano)
+endfunction()
+
+das_nano_example(standalone_01_pure 01_pure pure_math.das)
+das_nano_example(standalone_02_heap 02_heap heap_demo.das)
+das_nano_example(standalone_03_closures 03_closures closures.das)
+das_nano_example(standalone_04_c_binding 04_c_binding blinker.das)
diff --git a/examples/standalone/CMakeLists.txt b/examples/standalone/CMakeLists.txt
new file mode 100644
index 0000000000..b72868fd9a
--- /dev/null
+++ b/examples/standalone/CMakeLists.txt
@@ -0,0 +1,47 @@
+###########################################################
+# Standalone-AOT examples built against libDaScriptNano.
+#
+# Each example is a .das compiled to C++ ahead of time plus a C++ main that
+# links the generated code and nano - no daslang binary at run time, and no
+# compiler in the linked program at all.
+#
+# SDK users build the same four targets from CMakeLists.standalone.cmake.
+###########################################################
+
+# nano decides the header search order for everything that links it, and a
+# directory-level include_directories() is searched BEFORE any target's own.
+# Inheriting the root's would put the full include/ ahead of the headers nano
+# shadows - the program would link nano while compiling against the full
+# runtime's Context, which is a mismatch no test would name.
+set_property(DIRECTORY PROPERTY INCLUDE_DIRECTORIES "")
+
+set(NANO_EXAMPLE_GEN "${CMAKE_CURRENT_BINARY_DIR}/_standalone_ctx_generated")
+file(MAKE_DIRECTORY "${NANO_EXAMPLE_GEN}")
+
+function(das_nano_example name dir das_file)
+ set(_gen_cpp "${NANO_EXAMPLE_GEN}/${das_file}.cpp")
+ add_custom_command(
+ OUTPUT "${_gen_cpp}" "${NANO_EXAMPLE_GEN}/${das_file}.h"
+ COMMAND $
+ "${PROJECT_SOURCE_DIR}/utils/aot/main.das"
+ -- -ctx "${CMAKE_CURRENT_SOURCE_DIR}/${dir}/${das_file}"
+ "${NANO_EXAMPLE_GEN}/"
+ DEPENDS daslang
+ "${CMAKE_CURRENT_SOURCE_DIR}/${dir}/${das_file}"
+ "${PROJECT_SOURCE_DIR}/utils/aot/main.das"
+ "${PROJECT_SOURCE_DIR}/daslib/aot_standalone.das"
+ "${PROJECT_SOURCE_DIR}/daslib/aot_cpp.das"
+ WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}"
+ COMMENT "Standalone AOT: ${das_file}"
+ VERBATIM
+ )
+ add_executable(${name} "${CMAKE_CURRENT_SOURCE_DIR}/${dir}/main.cpp" "${_gen_cpp}")
+ target_include_directories(${name} PRIVATE "${NANO_EXAMPLE_GEN}")
+ target_link_libraries(${name} PRIVATE libDaScriptNano)
+ set_target_properties(${name} PROPERTIES FOLDER "examples/standalone")
+endfunction()
+
+das_nano_example(standalone_01_pure 01_pure pure_math.das)
+das_nano_example(standalone_02_heap 02_heap heap_demo.das)
+das_nano_example(standalone_03_closures 03_closures closures.das)
+das_nano_example(standalone_04_c_binding 04_c_binding blinker.das)
diff --git a/include/daScript/simulate/annotation_arguments.h b/include/daScript/simulate/annotation_arguments.h
index 29d4f66534..dd3ec78257 100644
--- a/include/daScript/simulate/annotation_arguments.h
+++ b/include/daScript/simulate/annotation_arguments.h
@@ -46,9 +46,9 @@ namespace das {
struct DAS_API AnnotationArgumentList : AnnotationArguments {
const AnnotationArgument * find ( const string & name, Type type ) const;
bool getBoolOption(const string & name, bool def = false) const;
- int32_t getIntOption(const string & name, int32_t def = false) const;
- uint64_t getUInt64Option(const string & name, uint64_t def = false) const;
- uint64_t getUInt64OptionEx (const string & name, const string & name2, uint64_t def = false) const;
+ int32_t getIntOption(const string & name, int32_t def = 0) const;
+ uint64_t getUInt64Option(const string & name, uint64_t def = 0) const;
+ uint64_t getUInt64OptionEx (const string & name, const string & name2, uint64_t def = 0) const;
void serialize ( AstSerializer & ser );
};
}
diff --git a/include/daScript/simulate/simulate.h b/include/daScript/simulate/simulate.h
index 586c501a0c..158e29eadd 100644
--- a/include/daScript/simulate/simulate.h
+++ b/include/daScript/simulate/simulate.h
@@ -730,7 +730,7 @@ namespace das
throw_error_at(line, "stack overflow while calling %s",fn->mangledName);
}
// fill prologue
- auto aa = abiArg; auto acm = cmres;
+ auto aa = abiArg; auto acm = abiCMRES;
abiArg = args; abiCMRES = cmres;
#if DAS_SANITIZER
memset(stack.sp(), 0xcd, fn->stackSize);
diff --git a/install/CLAUDE.md b/install/CLAUDE.md
index 0ce47c79bc..a1f4acefea 100644
--- a/install/CLAUDE.md
+++ b/install/CLAUDE.md
@@ -53,7 +53,7 @@ Task-specific instructions are in skill files under `skills/`. Read the relevant
| `skills/mcp_tools.md` | Full MCP tool table + live-API reference |
| `skills/das_formatting.md` | Creating or modifying any `.das` file |
| `skills/comment_style_hygiene.md` | Writing or reviewing comments, names, or local code shape in ANY language |
-| `skills/cpp_integration.md` | Embedding daslang in C++; binding types/functions/enums |
+| `skills/cpp_integration.md` | Embedding daslang in C++; binding types/functions/enums; shipping without the compiler (`libDaScriptNano`) |
| `skills/daslib_modules.md` | Using `daslib/` modules (linq, json, regex, etc.) |
| `skills/das_macros.md` | Compile-time macros, AST manipulation, qmacro/quote, gc_node patterns |
| `skills/daspkg.md` | Creating `.das_package` manifests, daspkg commands |
diff --git a/nano/ARCHITECTURE.md b/nano/ARCHITECTURE.md
new file mode 100644
index 0000000000..8c0e7bc20f
--- /dev/null
+++ b/nano/ARCHITECTURE.md
@@ -0,0 +1,193 @@
+# libDaScriptNano - architecture
+
+## What nano is
+
+A daslang runtime with no compiler in it. Standalone AOT (`daslang utils/aot/main.das -- -ctx
+script.das out/`) turns a script into C++ that an embedder compiles into their program; nano is
+what that C++ links against instead of `libDaScript`.
+
+The full runtime is one library because the compiler and the runtime share headers. An embedder
+who only wants to run already-compiled code still pays for the AST, the module registry, the
+debugger, the serializer, fmt, and the STL those drag in. On a desktop that is a link-time
+annoyance. On a microcontroller it is the difference between shipping and not.
+
+nano is not a fork of the runtime. It is the same runtime with a different set of headers in
+front of it.
+
+## The mechanism: a shadow include root
+
+`nano/include` goes **before** `include/` in the header search order. A header that exists in
+both resolves to nano's; every other header is the upstream file, byte for byte.
+
+That is the whole trick, and it buys the property that matters: the shared sources compile
+against nano's `Context` without one `#ifdef`, and regular AOT output keeps its exact shape. A
+build with `DAS_CONFIG_INCLUDE_DIR` (`cmake/das_config_eastl/`) already proved the pattern for
+`das_config.h` alone; nano widens it to four headers.
+
+### The include-order trap
+
+CMake searches a directory's `include_directories()` **before** any target's own. A target that
+links `libDaScriptNano` from a directory that inherited the root's `include_directories(include)`
+compiles against the full runtime's `Context` while linking nano's - a mismatch with no
+diagnostic, because most of the two agree. Every directory holding a nano-linked target clears
+that property:
+
+```cmake
+set_property(DIRECTORY PROPERTY INCLUDE_DIRECTORIES "")
+```
+
+This repo's `nano/REVIEW.das` checks it mechanically, because it is the one mistake in this
+design that produces a program rather than an error.
+
+## The include contract
+
+Generated standalone code includes exactly eight `daScript/...` paths, and nothing it emits
+widens that set:
+
+```
+daScript/misc/platform.h
+daScript/simulate/simulate.h
+daScript/simulate/aot.h
+daScript/simulate/aot_library.h
+daScript/simulate/standalone_ctx_utils.h
+daScript/simulate/bin_serializer.h // require $
+daScript/simulate/runtime_profile.h // require $
+daScript/misc/performance_time.h // require $
+```
+
+The last three arrive because the `$` builtin module declares them, never because generated code
+names a symbol from them. nano shadows all three with empty headers, which is compensation: the
+real fix is for a standalone context not to emit those three includes at all, and it lands with
+the next change to this folder.
+
+## What nano shadows
+
+`daScript/das_config.h` - the header everything else reaches the standard library through, which
+is why replacing it re-points the whole runtime at a smaller set. It drops the STL headers the
+runtime does not use, turns `DAS_ENABLE_EXCEPTIONS`, `DAS_DEBUGGER`, `DAS_FUSION`,
+`DAS_BIND_EXTERNAL` and the crash handler off, and routes
+`das_to_stdout_level_prefix_text` at the embedder's print sink. Those five are `#undef`ed and
+redefined rather than defaulted with `#ifndef`, so a build that sets one on the command line
+cannot silently get a runtime nano was not built to be - the panic path in particular is
+`setjmp`/`longjmp`, not a C++ exception, and there is no second version of it here.
+
+`daScript/simulate/simulate.h` - the `Context` subset standalone AOT actually touches: a stack,
+two heaps, the function and global tables, the three mangled-name lookups, and a panic path.
+Gone: debug agents, stack walkers, GC roots, job-fork pools, the profiler, JIT hooks,
+instrumentation, context cloning, code relocation, the init and shutdown scripts (the generated
+constructor runs the init script itself).
+
+`daScript/ast/ast.h` - not the compiler front-end but the handful of names two reused runtime
+sources want from it: `Annotation`, `TypeAnnotation`, and a `Module` with a name.
+
+The three `require $` headers above.
+
+## What nano owns
+
+`src/nano_context.cpp` - the `Context` implementation. Every function here has a counterpart in
+`src/runtime/context.cpp` or `src/simulate/simulate_exceptions.cpp`.
+
+`src/nano_stubs.cpp` - the seams where nano ends, plus the smart-pointer tracking globals that
+`src/misc/globals.cpp` owns upstream (that file also carries the job-queue globals, and
+`job_que.h` is threads).
+
+`src/nano_string_writer.cpp` - `daScript/misc/string_writer.h` implemented over `snprintf`. The
+header is upstream and unmodified; only fmt had to go, and it was reached through one template.
+
+## What nano reuses verbatim
+
+Thirteen sources compile straight out of `src/`, listed in `nano/CMakeLists.txt`. Adding one is a
+decision: it must compile with no edit to the shared tree. When it needs an edit, the fix goes
+upstream as a **carve** - splitting the runtime half of a file away from its compiler half - not
+into a fork here. `src/simulate/simulate_gc_pod.cpp`, `src/simulate/annotation_arguments.cpp`,
+`src/simulate/escape_string.cpp`, `src/simulate/builtin_array_ops.cpp` and
+`src/simulate/builtin_runtime_ops.cpp` all exist because of that rule, and every one of them left
+the full runtime better factored than it found it.
+
+## Fail-closed seams
+
+A stub that quietly does nothing is worse than no stub: on a board with no debugger, the moment
+of the call is the only diagnosis anyone gets. Each of these stops the program instead:
+
+- `SimNode::copyNode`, `SimNode::visit`, `SimNode_CallBase::copyNode`, `SimVisitor::sub` - code
+ relocation and node dumping, neither of which nano has.
+- `TypeAnnotation::walk` - walking a handled (C++-bound) type needs a registration only the
+ compiler makes.
+- `makeAotJitNode` - a context generated with JIT nodes was generated for a different runtime.
+- `FileInfo::serialize` - nano carries no serializer.
+- `getSemanticHash` - nano carries no semantic hashes.
+
+`Context::setup` checks the `CodeOfPolicies` ABI stamp and stops on a mismatch. On nano both
+sides are always built together, so a mismatch means the generated code and these headers came
+from different daslang builds - the one failure that cannot be detected any later.
+
+Three neighbours of those seams answer instead of stopping, each matching what the full runtime
+does when the thing it would consult is absent. `TypeInfo::resolveAnnotation` does nothing,
+because resolving means asking a `Module`. `getCancelLimit` returns zero, which upstream means
+"no cap" and is what an unbound environment returns there too. `print_current_stack_trace` is
+empty; `os_debug_break` traps.
+
+## Panic
+
+Same contract as the full runtime built with `DAS_ENABLE_EXCEPTIONS=0`: fill in the message,
+then `longjmp` to whoever armed `throwBuf`, or report and stop. One difference - the message
+lands in a fixed 256-byte buffer rather than a `das::string`, because running out of heap is one
+of the things that panics, and a longer message is truncated rather than allocated for.
+
+## Tiers
+
+A tier is what a script uses, not a build option: linking simply fails when a script reaches past
+what nano carries. `examples/standalone/` has one example per tier, and this repo additionally
+links all four into one program as the `nano_ctx` test.
+
+| tier | what it uses | example |
+|---|---|---|
+| A | POD compute - structs, enums, dims, no heap | `01_pure` |
+| B | arrays, tables, `new`/`delete` through the scope-free path | `02_heap` |
+| C | lambdas, function pointers, generators - the runtime function tables | `03_closures` |
+| output | `print` reaching the embedder's sink | `04_c_binding` |
+
+Above these sits everything `src/simulate/runtime_string.cpp` provides - string interpolation and
+the string builders - which nano leaves out because it is where fmt comes back. A script that
+uses them fails to link, which is the boundary being honest rather than a program that silently
+grew by a hundred kilobytes.
+
+Two builtin modules a host takes for granted are also absent, for the same reason: a script that
+calls `max` needs `require math`, and the math module is a registration nano has no compiler to
+make.
+
+## Ledgered cases
+
+**nano is not freestanding yet.** It builds where the full runtime builds, on the same
+toolchains, and drops the compiler. Cross-compiling it for a bare-metal target does not work
+today. What stands in the way, measured against arm-none-eabi with newlib: it has no
+`posix_memalign`, `malloc_usable_size` or `madvise`; its libstdc++ is built without threads, so
+`` declares nothing and neither `smart_ptr.h`'s ref-count lock nor this folder's
+`contextMutex` compiles; its `uint32_t` is `unsigned long`, which makes every `BitfieldAny`
+conversion and every `vec4` load ambiguous; and `alloca` needs its own include. Those
+sit in `platform.h`, `smart_ptr.h`, `arraytype.h`, `vectypes.h` and `interop.h` - shared headers
+every platform compiles - so the port is its own change, not a corner of nano.
+
+**`` and `` are still included.** `smart_ptr.h` declares a `static mutex` for
+its ref-count tracking list and `memory_model.h` types its custom-grow hook as `das::function`.
+Shadowing those two headers is part of the freestanding port above.
+
+**Floats print differently.** The full runtime formats through fmt, which prints the shortest
+round-tripping form; nano prints `%g`. Same value, shorter text. This shows only in log output.
+
+**No stack walk on panic.** The prologue writes stay on, because the generated code's frame sizes
+were computed assuming them, but nothing reads them - a panic reports its message and its
+`LineInfo`. An unwinder is the obvious thing to add here and nobody has needed it yet.
+
+**No GC.** `simulate_gc_pod.cpp` gives explicit `delete` its scope-free path; there is no
+collector behind it. What a script does not delete stays allocated until the context dies. For a
+program with a fixed working set - which is what this tier is for - that is the correct trade,
+but it is a trade.
+
+## Heap
+
+nano reuses `MemoryModel` and the heap allocators unchanged, so `options heap_size_hint` reaches
+`setInitialSize` and the allocator makes one big-path allocation up front. `options
+persistent_heap` picks `PersistentHeapAllocator` over the linear one, exactly as on a host.
+`options stack = N` is honored exactly - the 16384 floor applies only when a script sets no stack
+at all.
diff --git a/nano/CMakeLists.txt b/nano/CMakeLists.txt
new file mode 100644
index 0000000000..4f4cd3e7a1
--- /dev/null
+++ b/nano/CMakeLists.txt
@@ -0,0 +1,58 @@
+###########################################################
+# libDaScriptNano - the minimal daslang runtime.
+#
+# The whole mechanism is the include order below: nano/include comes BEFORE the
+# regular include/, so a header nano shadows wins and every other header is the
+# upstream file, byte for byte. That is why NANO_SHARED_SRC can list sources
+# straight out of src/ - they compile against nano's Context without knowing it.
+#
+# Adding a source here is a decision, not a convenience: it must compile with no
+# edit to the shared tree. If it needs one, the fix goes upstream (a carve, the
+# way src/simulate/simulate_gc_pod.cpp and src/simulate/annotation_arguments.cpp
+# were carved out) rather than into a fork.
+###########################################################
+
+# The daslang root: the tree in-repo, the SDK root once installed.
+if(NOT DEFINED DASLANG_NANO_ROOT)
+ get_filename_component(DASLANG_NANO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE)
+endif()
+
+set(NANO_OWN_SRC
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/nano_context.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/nano_stubs.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/src/nano_string_writer.cpp
+)
+
+set(NANO_SHARED_SRC
+ ${DASLANG_NANO_ROOT}/src/misc/hal.cpp
+ ${DASLANG_NANO_ROOT}/src/misc/memory_model.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/annotation_arguments.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/aot_library.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/builtin_array_ops.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/builtin_runtime_ops.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/data_walker.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/debug_info.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/escape_string.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/heap.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/runtime_array.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/runtime_iterator.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/runtime_table.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/simulate_gc_pod.cpp
+ ${DASLANG_NANO_ROOT}/src/simulate/standalone_ctx_utils.cpp
+)
+
+add_library(libDaScriptNano STATIC ${NANO_OWN_SRC} ${NANO_SHARED_SRC})
+
+target_include_directories(libDaScriptNano BEFORE PUBLIC
+ $
+ $
+ $
+ $
+)
+
+target_compile_features(libDaScriptNano PUBLIC cxx_std_17)
+set_target_properties(libDaScriptNano PROPERTIES FOLDER "nano")
+
+if(MSVC)
+ target_compile_options(libDaScriptNano PRIVATE /wd4100 /wd4127)
+endif()
diff --git a/nano/README.md b/nano/README.md
new file mode 100644
index 0000000000..8dc72d0ec1
--- /dev/null
+++ b/nano/README.md
@@ -0,0 +1,79 @@
+# libDaScriptNano
+
+The daslang runtime with no compiler in it - for programs that run
+already-compiled daslang and never compile any.
+
+You compile your script to C++ ahead of time on your workstation:
+
+```bash
+daslang utils/aot/main.das -- -ctx my_script.das generated/
+```
+
+and link the result against nano instead of `libDaScript`. The generated
+`my_script.das.h` gives you a plain C++ class:
+
+```cpp
+#include "daScript/nano_print.h"
+#include "my_script.das.h"
+
+int main () {
+ das::das_nano_set_print(&my_log_sink); // where print goes
+ my_script::Standalone ctx; // construct it
+ int answer = ctx.my_exported_function(21); // call it
+}
+```
+
+No `daScript.h`, no module registration, no `Program`, no file system. The
+context is an object with a lifetime.
+
+## What it costs
+
+The four examples under `examples/standalone/`, built for x64 with MSVC, next to
+the equivalent program on the full runtime:
+
+| program | tier | size |
+|---|---|---|
+| `01_pure` | POD compute, no heap | 120 KB |
+| `02_heap` | arrays, tables, `new`/`delete` | 129 KB |
+| `03_closures` | lambdas, function pointers, generators | 139 KB |
+| `04_c_binding` | C drives the loop, das decides, `print` | 151 KB |
+| full-runtime standalone context, for scale | | 463 KB |
+
+Those numbers include the platform's C runtime, so what they measure is the
+difference nano makes on a host - not an embedded footprint.
+
+**nano does not cross-compile freestanding yet.** It runs on the platforms the
+full runtime runs on, minus the compiler. Building it for a bare-metal target
+needs portability work in the shared headers; `ARCHITECTURE.md` lists what.
+
+## What it leaves out
+
+A script that reaches past what nano carries fails to **link** - the boundary
+announces itself at build time rather than becoming a program that quietly grew.
+Absent: string interpolation and the string builders (this is where fmt comes
+back), every builtin module including `math`, the GC, the debugger, the JIT, the
+serializer, the profiler, threads, and the file system.
+
+`ARCHITECTURE.md` beside this file explains what nano is made of and lists the
+trades it makes.
+
+## Building it
+
+In this repository nano builds as part of the normal CMake configure, and the
+examples build with it. Outside it, point `DASLANG_NANO_ROOT` at an installed
+SDK root and `add_subdirectory` this folder:
+
+```cmake
+set(DASLANG_NANO_ROOT "/path/to/daslang-sdk")
+add_subdirectory("${DASLANG_NANO_ROOT}/nano" nano_build)
+
+# Anything linking nano lets nano decide the header search order.
+set_property(DIRECTORY PROPERTY INCLUDE_DIRECTORIES "")
+add_executable(my_program main.cpp generated/my_script.das.cpp)
+target_include_directories(my_program PRIVATE generated)
+target_link_libraries(my_program PRIVATE libDaScriptNano)
+```
+
+That `set_property` line is load-bearing, not tidiness: without it your target
+compiles against the full runtime's headers while linking nano's library.
+`ARCHITECTURE.md` says why under "The include-order trap".
diff --git a/nano/REVIEW.das b/nano/REVIEW.das
new file mode 100644
index 0000000000..400c9ee95f
--- /dev/null
+++ b/nano/REVIEW.das
@@ -0,0 +1,140 @@
+options gen2
+
+require strings
+require daslib/fio
+require dastest/review_gate
+
+// The mechanical half of nano/REVIEW.md (contract: REVIEW_COMMON.md at the repo root).
+// Run from the repo root: bin/daslang nano/REVIEW.das - exit 0 clean, 1 with findings.
+
+let NANO_CMAKE = "nano/CMakeLists.txt"
+
+// Directories a checkout carries but git does not: build output, tool caches, and the
+// nested worktrees agents run in. Walking them costs minutes and finds nothing.
+let UNTRACKED_DIRS = {
+ ".git", ".claude", ".codex", ".vs", "build", "bin", "lib",
+ "node_modules", "out", "__pycache__", "_build"
+}
+
+def private walk_cmakelists(root : string; var out : array) {
+ dir(root) $(name) {
+ return if (name == "." || name == "..")
+ let p = root == "." ? name : path_join(root, name)
+ let st = stat(p)
+ return if (!st.is_valid)
+ if (st.is_dir) {
+ return if (key_exists(UNTRACKED_DIRS, name))
+ walk_cmakelists(p, out)
+ } elif (name == "CMakeLists.txt" || name == "CMakeLists.standalone.cmake") {
+ // the .standalone.cmake variants are installed AS CMakeLists.txt, so
+ // they carry the same obligation an SDK user will inherit
+ out |> push(to_generic_path(p))
+ }
+ }
+}
+
+// Sources the nano library compiles out of the shared tree, as CMake lists them.
+def private nano_shared_sources(text : string) : array {
+ var inscope entries <- cmake_list_entries(text, "NANO_SHARED_SRC")
+ let marker = "}/"
+ return <- [for (e in entries); find(e, marker) >= 0 ? slice(e, find(e, marker) + length(marker), length(e)) : e]
+}
+
+// Every shared source nano names is a file that is still there. A rename upstream would
+// otherwise surface only when somebody builds nano.
+def private check_shared_sources(text : string) {
+ for (src in nano_shared_sources(text)) {
+ continue if (fexist(src))
+ gate_finding(NANO_CMAKE, "NANO_SHARED_SRC names {src}, which does not exist - the file moved or was renamed upstream")
+ }
+}
+
+// A header under nano/include shadows an upstream header or is nano's own. A third case
+// is a header nothing in the build will ever reach.
+def private check_shadow_headers(folder : string; owned : array) {
+ dir(folder) $(name) {
+ return if (name == "." || name == "..")
+ let path = path_join(folder, name)
+ let st = stat(path)
+ return if (!st.is_valid)
+ if (st.is_dir) {
+ check_shadow_headers(path, owned)
+ return
+ }
+ return if (!(name |> ends_with(".h")))
+ let generic = to_generic_path(path)
+ let upstream = "include/{slice(generic, length("nano/include/"), length(generic))}"
+ return if (fexist(upstream) || find_index(owned, name) >= 0)
+ gate_finding(generic, "shadows nothing: there is no {upstream}, and it is not one of nano's own headers - either the upstream header moved or this file is unreachable")
+ }
+}
+
+// Paths the root CMakeLists installs into src/, as install(FILES ...) lists them.
+def private installed_src_files(text : string) : array {
+ var out : array
+ for (blk in cmake_command_blocks(text, "install")) {
+ var inscope args <- cmake_args(blk)
+ continue if (find_index(args, "FILES") != 0)
+ var dest = ""
+ for (i in range(1, length(args))) {
+ if (args[i] == "DESTINATION" && i + 1 < length(args)) {
+ dest = args[i + 1]
+ }
+ }
+ continue if (!(dest |> starts_with("src/")))
+ let marker = "}/"
+ out |> reserve(length(out) + length(args))
+ for (a in args) {
+ let at = find(a, marker)
+ out |> push(at >= 0 ? slice(a, at + length(marker), length(a)) : a)
+ }
+ }
+ return <- out
+}
+
+// An SDK carries no src/ except the sources nano compiles, so a source added to
+// NANO_SHARED_SRC and not to the root install list builds here and not from an SDK.
+def private check_shared_sources_ship(text : string) {
+ let root_cmake = "CMakeLists.txt"
+ var inscope installed <- installed_src_files(fread(root_cmake))
+ for (src in nano_shared_sources(text)) {
+ continue if (find_index(installed, src) >= 0)
+ gate_finding(root_cmake, "{src} is in NANO_SHARED_SRC but no install(FILES ...) puts it under src/ - an SDK build of nano would not find it")
+ }
+}
+
+// The include-order trap: CMake searches a directory's include_directories() before any
+// target's own, so a nano-linked target in a directory that inherited the repo's would
+// compile against the full runtime's headers while linking nano's library.
+def private links_nano(text : string) : bool {
+ for (blk in cmake_command_blocks(text, "target_link_libraries")) {
+ var inscope args <- cmake_args(blk)
+ return true if (find_index(args, "libDaScriptNano") >= 0)
+ }
+ return false
+}
+
+def private check_include_order_guard {
+ var files : array
+ walk_cmakelists(".", files)
+ files |> sort
+ for (f in files) {
+ let text = fread(f)
+ continue if (!links_nano(text) || find(text, "INCLUDE_DIRECTORIES \"\"") >= 0)
+ gate_finding(f, "links libDaScriptNano without clearing inherited include directories - add set_property(DIRECTORY PROPERTY INCLUDE_DIRECTORIES \"\"), or the target compiles against the full runtime's headers")
+ }
+}
+
+[export]
+def main() : int {
+ if (!fexist(NANO_CMAKE)) {
+ to_log(LOG_ERROR, "nano/REVIEW.das: run from the repo root\n")
+ return 2
+ }
+ let cmake_text = fread(NANO_CMAKE)
+ check_shared_sources(cmake_text)
+ check_shared_sources_ship(cmake_text)
+ check_shadow_headers("nano/include", ["nano_print.h"])
+ check_include_order_guard()
+ return gate_verdict("nano")
+}
diff --git a/nano/REVIEW.md b/nano/REVIEW.md
new file mode 100644
index 0000000000..679809a4ef
--- /dev/null
+++ b/nano/REVIEW.md
@@ -0,0 +1,42 @@
+# nano Code Review Checklist
+
+**Read `REVIEW_COMMON.md` (repo root) first - its contract binds this checklist.** Architecture doc:
+`ARCHITECTURE.md`.
+
+**A shared source added to `NANO_SHARED_SRC` compiles with no edit to the file itself.**
+A source that needs an edit to build here is split upstream instead - its runtime half moved
+into a file of its own - because an edited copy is a fork that drifts silently.
+
+**A source under `src/` that nano compiles must not include `daScript/ast/ast.h` for anything
+beyond `Annotation`, `TypeAnnotation` and `Module::name`.** Those three are all nano's
+`include/daScript/ast/ast.h` provides; anything else compiles here and means something
+different than it does upstream.
+
+**A new stub in `src/nano_stubs.cpp` either implements the behaviour or stops the program.**
+A stub that returns a default and continues turns a missing feature into a wrong answer on a
+target with no debugger attached.
+
+**A directory holding a target that links `libDaScriptNano` clears its inherited include
+directories.** CMake searches a directory's `include_directories()` before any target's own,
+so without `set_property(DIRECTORY PROPERTY INCLUDE_DIRECTORIES "")` the target compiles
+against the full runtime's headers while linking nano's library.
+
+**A name added to `include/daScript/simulate/simulate.h` here is a name something in the reuse
+set or in generated code refers to.** This header is a subset, not a copy: a name added
+speculatively is one nobody will know to remove.
+
+**A member kept in this folder's `Context` keeps the name and type it has in
+`include/daScript/simulate/simulate.h` at the repo root.** Generated code and the reused
+headers are written against those names, so a renamed member is a compile error at best and a
+different field at worst.
+
+**A change to what nano leaves out updates the tier table in `ARCHITECTURE.md` and the
+"What it leaves out" list in `README.md`.** Both are read by embedders deciding whether their
+script fits.
+
+**A new tier or a new fail-closed seam ships an example under `examples/standalone/` and a
+case in `tests-cpp/big/nano_ctx/test_nano_ctx.cpp`.** A tier with no linked program is a tier
+that stops working without anything turning red.
+
+**A number in `README.md` was measured, not estimated.** The table says which toolchain and
+which targets produced it, so a reader can reproduce it.
diff --git a/nano/include/daScript/ast/ast.h b/nano/include/daScript/ast/ast.h
new file mode 100644
index 0000000000..0e1d7fe6d7
--- /dev/null
+++ b/nano/include/daScript/ast/ast.h
@@ -0,0 +1,47 @@
+#pragma once
+
+// nano shadow of daScript/ast/ast.h.
+//
+// The real header is the compiler front-end: the AST, the type system, the
+// module registry - tens of thousands of lines that a program which was already
+// compiled has no use for. Two reused runtime sources still include it, and both
+// want the same one thing from it: the type a handled (C++-bound) TypeInfo
+// resolves to, so the data walker can hand a value to its annotation.
+//
+// nano has no modules, so nothing ever resolves, and the walk below stops the
+// program instead of walking a type nobody described. BasicAnnotation itself
+// comes from debug_info.h upstream; the two runtime includes are here because
+// the real ast.h reaches Sequence and tableLiveSlot through its own chain, and
+// the sources that include this file use them.
+
+#include "daScript/misc/platform.h"
+#include "daScript/misc/string_writer.h"
+#include "daScript/simulate/debug_info.h"
+#include "daScript/simulate/annotation_arguments.h"
+#include "daScript/simulate/runtime_iterator.h"
+#include "daScript/simulate/runtime_table.h"
+
+namespace das {
+
+ struct DataWalker;
+
+ struct Module {
+ string name;
+ };
+
+ struct Annotation : BasicAnnotation {
+ Annotation ( const string & n, const string & cpn = "" ) : BasicAnnotation(n,cpn) {}
+ virtual bool rtti_isHandledTypeAnnotation() const { return false; }
+ Module * module = nullptr;
+ };
+
+ struct TypeAnnotation : Annotation {
+ TypeAnnotation ( const string & n, const string & cpn = "" ) : Annotation(n,cpn) {}
+ virtual bool rtti_isHandledTypeAnnotation() const override { return true; }
+ virtual size_t getSizeOf() const { return sizeof(void *); }
+ virtual size_t getAlignOf() const { return 1; }
+ virtual void walk ( DataWalker &, void * ) {
+ DAS_FATAL_ERROR("TypeAnnotation::walk: walking a handled type needs the full runtime, which nano is not");
+ }
+ };
+}
diff --git a/nano/include/daScript/das_config.h b/nano/include/daScript/das_config.h
new file mode 100644
index 0000000000..28b127a8f3
--- /dev/null
+++ b/nano/include/daScript/das_config.h
@@ -0,0 +1,108 @@
+#pragma once
+
+// nano shadow of daScript/das_config.h.
+//
+// This header is the reason nano works at all: everything in include/ reaches
+// the standard library through it, so replacing it - by putting nano/include
+// ahead of include/ in the search order - re-points the whole runtime at a
+// smaller set of headers without one #ifdef in the shared sources.
+//
+// Pre-C++20 inclusion order note: platform.h pulls this in before it defines
+// __forceinline / DAS_SUPPRESS_UB / NO_ASAN_INLINE, so anything included here
+// must not use those macros without its own fallback.
+
+#ifndef DAS_CUSTOM_HASH
+#define DAS_CUSTOM_HASH 1
+#endif
+
+#include