diff --git a/Makefile b/Makefile index 4185687..3b3795b 100644 --- a/Makefile +++ b/Makefile @@ -107,7 +107,7 @@ SDKDIR = bin/sdk deploy-sdk: @echo "Building SDK..." - @mkdir -p $(SDKDIR)/include/core $(SDKDIR)/include/shell $(SDKDIR)/include/tasks $(SDKDIR)/include/sql $(SDKDIR)/include/basic + @mkdir -p $(SDKDIR)/include/core $(SDKDIR)/include/shell $(SDKDIR)/include/tasks $(SDKDIR)/include/sql $(SDKDIR)/include/basic $(SDKDIR)/samples/basic/basdemo @# Core headers (libdvx public API) @for f in src/libs/kpunch/libdvx/dvxApp.h src/libs/kpunch/libdvx/dvxTypes.h \ src/libs/kpunch/libdvx/dvxWgt.h src/libs/kpunch/libdvx/dvxWgtP.h \ @@ -137,6 +137,10 @@ deploy-sdk: done @# BASIC include files @cp src/include/basic/*.bas $(SDKDIR)/include/basic/ + @# BASIC sample: basdemo (project file + form + icon) + @cp src/apps/kpunch/basdemo/basdemo.dbp $(SDKDIR)/samples/basic/basdemo/ + @cp src/apps/kpunch/basdemo/basdemo.frm $(SDKDIR)/samples/basic/basdemo/ + @cp src/apps/kpunch/basdemo/ICON32.BMP $(SDKDIR)/samples/basic/basdemo/ @# README @printf '%s\n' \ 'DVX SDK' \ @@ -154,6 +158,8 @@ deploy-sdk: ' sql/ SQLite database wrapper API' \ ' widget/ Per-widget public API headers' \ ' basic/ BASIC include files (DECLARE LIBRARY modules)' \ + ' samples/' \ + ' basic/ Example BASIC projects (open in the DVX BASIC IDE)' \ '' \ 'Requirements' \ '------------' \ diff --git a/docs/dvx_basic_reference.html b/docs/dvx_basic_reference.html index 81a6f59..7c0da39 100644 --- a/docs/dvx_basic_reference.html +++ b/docs/dvx_basic_reference.html @@ -3188,6 +3188,7 @@ End Sub

Type-Specific Methods

  Method                                          Description
   ------                                          -----------
+  AppendText text$                                Append text to the end of the buffer and invalidate the widget.
   FindNext needle$, caseSensitive, forward        Search for text. Returns True if found.
   GetWordAtCursor()                               Returns the word under the cursor.
   GoToLine line%                                  Scroll to and position cursor at the given line.
diff --git a/docs/dvx_system_reference.html b/docs/dvx_system_reference.html
index e669bd9..598114a 100644
--- a/docs/dvx_system_reference.html
+++ b/docs/dvx_system_reference.html
@@ -6977,6 +6977,7 @@ WidgetT *page2 = wgtTabPage(tabs, "Advanced");
 

API Functions (TextArea-specific)

  Function                                                    Description
   --------                                                    -----------
+  void wgtTextAreaAppendText(w, text)                         Append text to the end of the buffer and invalidate the widget.
   void wgtTextAreaSetColorize(w, fn, ctx)                     Set a syntax colorization callback. The callback receives each line and fills a color index array.
   void wgtTextAreaGoToLine(w, line)                           Scroll to and place the cursor on the given line number.
   void wgtTextAreaSetAutoIndent(w, enable)                    Enable or disable automatic indentation on newline.
diff --git a/src/apps/kpunch/Makefile b/src/apps/kpunch/Makefile
index b0aeda8..0ad2700 100644
--- a/src/apps/kpunch/Makefile
+++ b/src/apps/kpunch/Makefile
@@ -39,7 +39,7 @@ C_APPS = progman clock dvxdemo cpanel dvxhelp
 BASCOMP   = ../../../bin/host/bascomp
 # BASIC apps: each is a .dbp project in its own directory.
 # BASIC-only notepad, imgview, etc. replace the old C versions.
-BASIC_APPS = iconed notepad imgview helpedit resedit basicdemo
+BASIC_APPS = iconed notepad imgview helpedit resedit basdemo
 
 .PHONY: all clean $(C_APPS) dvxbasic $(BASIC_APPS)
 
@@ -115,10 +115,10 @@ resedit: $(BINDIR)/kpunch/resedit/resedit.app
 $(BINDIR)/kpunch/resedit/resedit.app: resedit/resedit.dbp resedit/resedit.frm resedit/ICON32.BMP $(BASCOMP) | $(BINDIR)/kpunch/resedit dvxbasic
 	$(BASCOMP) resedit/resedit.dbp -o $@ -release
 
-basicdemo: $(BINDIR)/kpunch/basicdemo/basicdemo.app
+basdemo: $(BINDIR)/kpunch/basdemo/basdemo.app
 
-$(BINDIR)/kpunch/basicdemo/basicdemo.app: basicdemo/basicdemo.dbp basicdemo/basicdemo.frm basicdemo/ICON32.BMP $(BASCOMP) | $(BINDIR)/kpunch/basicdemo dvxbasic
-	$(BASCOMP) basicdemo/basicdemo.dbp -o $@ -release
+$(BINDIR)/kpunch/basdemo/basdemo.app: basdemo/basdemo.dbp basdemo/basdemo.frm basdemo/ICON32.BMP $(BASCOMP) | $(BINDIR)/kpunch/basdemo dvxbasic
+	$(BASCOMP) basdemo/basdemo.dbp -o $@ -release
 
 $(OBJDIR):
 	mkdir -p $(OBJDIR)
@@ -132,7 +132,7 @@ $(BINDIR)/kpunch/iconed:    ; mkdir -p $@
 $(BINDIR)/kpunch/notepad:   ; mkdir -p $@
 $(BINDIR)/kpunch/imgview:   ; mkdir -p $@
 $(BINDIR)/kpunch/resedit:   ; mkdir -p $@
-$(BINDIR)/kpunch/basicdemo: ; mkdir -p $@
+$(BINDIR)/kpunch/basdemo: ; mkdir -p $@
 
 # Header dependencies
 COMMON_H = ../../libs/kpunch/libdvx/dvxApp.h ../../libs/kpunch/libdvx/dvxDlg.h ../../libs/kpunch/libdvx/dvxWgt.h ../../libs/kpunch/libdvx/dvxWm.h ../../libs/kpunch/libdvx/dvxVideo.h ../../libs/kpunch/dvxshell/shellApp.h
@@ -153,5 +153,5 @@ clean:
 	rm -f $(BINDIR)/kpunch/imgview/imgview.app
 	rm -f $(BINDIR)/kpunch/dvxhelp/helpedit.app
 	rm -f $(BINDIR)/kpunch/resedit/resedit.app
-	rm -f $(BINDIR)/kpunch/basicdemo/basicdemo.app
+	rm -f $(BINDIR)/kpunch/basdemo/basdemo.app
 	$(MAKE) -C dvxbasic clean
diff --git a/src/apps/kpunch/README.md b/src/apps/kpunch/README.md
index f5e6615..9a58312 100644
--- a/src/apps/kpunch/README.md
+++ b/src/apps/kpunch/README.md
@@ -14,7 +14,7 @@ the BASIC-based sample apps. Two flavours of app live side by side:
 * **BASIC apps** -- `.dbp` projects compiled to `.app` files by the
   host-side `bascomp`. The compiler links the BASIC bytecode into a
   copy of `basstub.app`, which acts as the runtime host. See
-  `iconed/`, `notepad/`, `imgview/`, `basicdemo/`, `resedit/`,
+  `iconed/`, `notepad/`, `imgview/`, `basdemo/`, `resedit/`,
   `dvxhelp/helpedit/`.
 
 The rest of this document covers writing a C app against the SDK.
@@ -303,11 +303,11 @@ the compiled system help viewer by default for live preview.
 GUI wrapper around `dvxres`: open a `.app` / `.wgt` / `.lib`,
 browse its resources, add, replace, extract, or remove them.
 
-### BASIC Demo (basicdemo, BASIC)
+### BASIC Demo (basdemo, BASIC)
 
 | | |
 |---|---|
-| File | `apps/kpunch/basicdemo/basicdemo.app` |
+| File | `apps/kpunch/basdemo/basdemo.app` |
 | Type | BASIC (main-loop via basstub) |
 | Multi-instance | No |
 
@@ -453,9 +453,9 @@ apps/kpunch/
     imgview.dbp
     imgview.frm
     ICON32.BMP
-  basicdemo/
-    basicdemo.dbp
-    basicdemo.frm
+  basdemo/
+    basdemo.dbp
+    basdemo.frm
     ICON32.BMP
   resedit/
     resedit.dbp
diff --git a/src/apps/kpunch/basicdemo/ICON32.BMP b/src/apps/kpunch/basdemo/ICON32.BMP
similarity index 100%
rename from src/apps/kpunch/basicdemo/ICON32.BMP
rename to src/apps/kpunch/basdemo/ICON32.BMP
diff --git a/src/apps/kpunch/basicdemo/basicdemo.dbp b/src/apps/kpunch/basdemo/basdemo.dbp
similarity index 93%
rename from src/apps/kpunch/basicdemo/basicdemo.dbp
rename to src/apps/kpunch/basdemo/basdemo.dbp
index ac37c70..52d0ff6 100644
--- a/src/apps/kpunch/basicdemo/basicdemo.dbp
+++ b/src/apps/kpunch/basdemo/basdemo.dbp
@@ -11,7 +11,7 @@ File0 = ../../../include/basic/commdlg.bas
 Count = 1
 
 [Forms]
-File0 = basicdemo.frm
+File0 = basdemo.frm
 Count = 1
 
 [Settings]
diff --git a/src/apps/kpunch/basicdemo/basicdemo.frm b/src/apps/kpunch/basdemo/basdemo.frm
similarity index 97%
rename from src/apps/kpunch/basicdemo/basicdemo.frm
rename to src/apps/kpunch/basdemo/basdemo.frm
index 50a5d2b..e36531f 100644
--- a/src/apps/kpunch/basicdemo/basicdemo.frm
+++ b/src/apps/kpunch/basdemo/basdemo.frm
@@ -170,6 +170,21 @@ TYPE PointT
     y AS INTEGER
 END TYPE
 
+' Module-level state.  Grouped here up front (rather than sprinkled
+' between SUB definitions) so the compiler sees a simple top-level
+' run-once block followed by a flat list of SUBs / FUNCTIONs.
+DIM gfxWin    AS LONG
+DIM dynForm   AS LONG
+DIM dynCount  AS INTEGER
+DIM timerWin  AS LONG
+DIM tickCount AS LONG
+
+gfxWin    = 0
+dynForm   = 0
+dynCount  = 0
+timerWin  = 0
+tickCount = 0
+
 
 ' ============================================================
 ' OutArea helpers
@@ -247,7 +262,7 @@ SUB mnuAbout_Click
     msg = "DVX BASIC Feature Tour" + CHR$(10) + CHR$(10)
     msg = msg + "A visual catalog of DVX BASIC language"
     msg = msg + " and runtime features." + CHR$(10) + CHR$(10)
-    msg = msg + "(c) 2026 DVX Project"
+    msg = msg + "Copyright 2026 Scott Duensing"
     MsgBox msg, vbOKOnly, "About"
 END SUB
 
@@ -740,17 +755,13 @@ END SUB
 ' Graphics demo  (opens a second form with Canvas)
 ' ============================================================
 
-DIM gfxWin AS LONG
-gfxWin = 0
-
-
 SUB mnuGraphics_Click
     IF gfxWin <> 0 THEN
         EXIT SUB
     END IF
 
     DIM frm AS LONG
-    SET frm = CreateForm("GraphicsForm", 360, 320)
+    SET frm = CreateForm("GraphicsForm", 380, 360)
     GraphicsForm.Caption = "Graphics Demo"
     gfxWin = frm
 
@@ -844,6 +855,13 @@ SUB GfxClearCanvas
 END SUB
 
 
+SUB BasicDemo_Unload
+    ' Closing the main form shuts down the whole app, including any
+    ' child forms (Graphics, Dynamic, Timer) the user left open.
+    END
+END SUB
+
+
 SUB GraphicsForm_Unload
     gfxWin = 0
 END SUB
@@ -853,10 +871,6 @@ END SUB
 ' Dynamic form demo
 ' ============================================================
 
-DIM dynForm AS LONG
-dynForm = 0
-
-
 SUB mnuDynamic_Click
     IF dynForm <> 0 THEN
         EXIT SUB
@@ -892,10 +906,6 @@ SUB mnuDynamic_Click
 END SUB
 
 
-DIM dynCount AS INTEGER
-dynCount = 0
-
-
 SUB DynInc
     dynCount = dynCount + 1
     CountLabel.Caption = "Counter: " + STR$(dynCount)
@@ -917,10 +927,6 @@ END SUB
 ' Timer demo
 ' ============================================================
 
-DIM timerWin AS LONG
-timerWin = 0
-
-
 SUB mnuTimer_Click
     IF timerWin <> 0 THEN
         EXIT SUB
@@ -944,10 +950,6 @@ SUB mnuTimer_Click
 END SUB
 
 
-DIM tickCount AS LONG
-tickCount = 0
-
-
 SUB TickHandler
     tickCount = tickCount + 1
     TickLabel.Caption = "Ticks: " + STR$(tickCount) + "  Time: " + TIME$
diff --git a/src/apps/kpunch/cpanel/cpanel.c b/src/apps/kpunch/cpanel/cpanel.c
index 7690816..c4d15a7 100644
--- a/src/apps/kpunch/cpanel/cpanel.c
+++ b/src/apps/kpunch/cpanel/cpanel.c
@@ -49,7 +49,6 @@
 #include "shellApp.h"
 #include "stb_ds_wrap.h"
 
-#include 
 #include 
 #include 
 #include 
@@ -977,35 +976,33 @@ static void scanThemes(void) {
     arrsetlen(sThemeEntries, 0);
     arrsetlen(sThemeLabels, 0);
 
-    DIR *dir = opendir(THEME_DIR);
+    char **names = dvxReadDir(THEME_DIR);
 
-    if (!dir) {
+    if (!names) {
         return;
     }
 
-    struct dirent *ent;
+    int32_t n = (int32_t)arrlen(names);
 
-    while ((ent = readdir(dir)) != NULL) {
-        char *dot = strrchr(ent->d_name, '.');
-
-        if (!dot || strcasecmp(dot, THEME_EXT) != 0) {
+    for (int32_t i = 0; i < n; i++) {
+        if (!dvxHasExt(names[i], THEME_EXT)) {
             continue;
         }
 
-        FileEntryT entry = {0};
-        int32_t nameLen = (int32_t)(dot - ent->d_name);
+        FileEntryT    entry      = {0};
+        int32_t       nameLen    = (int32_t)strlen(names[i]) - (int32_t)strlen(THEME_EXT);
 
         if (nameLen >= (int32_t)sizeof(entry.name)) {
             nameLen = (int32_t)sizeof(entry.name) - 1;
         }
 
-        memcpy(entry.name, ent->d_name, nameLen);
+        memcpy(entry.name, names[i], nameLen);
         entry.name[nameLen] = '\0';
         snprintf(entry.path, sizeof(entry.path), "%s" DVX_PATH_SEP "%s", THEME_DIR, entry.name);
         arrput(sThemeEntries, entry);
     }
 
-    closedir(dir);
+    dvxReadDirFree(names);
 
     // Build label array now that sThemeEntries is stable
     for (int32_t i = 0; i < arrlen(sThemeEntries); i++) {
@@ -1018,24 +1015,18 @@ static void scanWallpapers(void) {
     arrsetlen(sWpaperEntries, 0);
     arrsetlen(sWpaperLabels, 0);
 
-    DIR *dir = opendir(WPAPER_DIR);
+    char **names = dvxReadDir(WPAPER_DIR);
 
-    if (!dir) {
+    if (!names) {
         return;
     }
 
-    struct dirent *ent;
+    int32_t n = (int32_t)arrlen(names);
 
-    while ((ent = readdir(dir)) != NULL) {
-        char *dot = strrchr(ent->d_name, '.');
-
-        if (!dot) {
-            continue;
-        }
-
-        if (strcasecmp(dot, ".BMP") != 0 &&
-            strcasecmp(dot, ".JPG") != 0 &&
-            strcasecmp(dot, ".PNG") != 0) {
+    for (int32_t i = 0; i < n; i++) {
+        if (!dvxHasExt(names[i], ".BMP") &&
+            !dvxHasExt(names[i], ".JPG") &&
+            !dvxHasExt(names[i], ".PNG")) {
             continue;
         }
 
@@ -1044,19 +1035,19 @@ static void scanWallpapers(void) {
         // Length-clamped memcpy instead of strncpy/snprintf because GCC
         // warns about both when d_name (255) exceeds the buffer (64),
         // even though truncation is intentional and safe.
-        int32_t nl = (int32_t)strlen(ent->d_name);
+        int32_t nl = (int32_t)strlen(names[i]);
 
         if (nl >= (int32_t)sizeof(entry.name)) {
             nl = (int32_t)sizeof(entry.name) - 1;
         }
 
-        memcpy(entry.name, ent->d_name, nl);
+        memcpy(entry.name, names[i], nl);
         entry.name[nl] = '\0';
         snprintf(entry.path, sizeof(entry.path), "%s" DVX_PATH_SEP "%s", WPAPER_DIR, entry.name);
         arrput(sWpaperEntries, entry);
     }
 
-    closedir(dir);
+    dvxReadDirFree(names);
 
     // Build label array now that sWpaperEntries is stable
     for (int32_t i = 0; i < arrlen(sWpaperEntries); i++) {
diff --git a/src/apps/kpunch/dvxbasic/Makefile b/src/apps/kpunch/dvxbasic/Makefile
index 7fbf74e..cf36ffa 100644
--- a/src/apps/kpunch/dvxbasic/Makefile
+++ b/src/apps/kpunch/dvxbasic/Makefile
@@ -43,7 +43,7 @@ HOSTDIR = ../../../../bin/host
 DVXRES  = $(HOSTDIR)/dvxres
 
 # Runtime library objects (VM + values + form runtime + serialization)
-RT_OBJS = $(OBJDIR)/vm.o $(OBJDIR)/values.o $(OBJDIR)/formrt.o $(OBJDIR)/serialize.o
+RT_OBJS = $(OBJDIR)/vm.o $(OBJDIR)/values.o $(OBJDIR)/formrt.o $(OBJDIR)/frmParser.o $(OBJDIR)/serialize.o
 RT_TARGETDIR = $(LIBSDIR)/kpunch/basrt
 RT_TARGET    = $(RT_TARGETDIR)/basrt.lib
 
@@ -68,6 +68,7 @@ TEST_VM       = $(HOSTDIR)/test_vm
 TEST_LEX      = $(HOSTDIR)/test_lex
 TEST_QUICK    = $(HOSTDIR)/test_quick
 TEST_COMPACT  = $(HOSTDIR)/test_compact
+TEST_SUITE    = $(HOSTDIR)/test_suite
 
 STB_DS_IMPL        = ../../../libs/kpunch/libdvx/thirdparty/stb_ds_impl.c
 PLATFORM_UTIL     = ../../../libs/kpunch/libdvx/platform/dvxPlatformUtil.c
@@ -76,6 +77,7 @@ TEST_VM_SRCS       = test_vm.c runtime/vm.c runtime/values.c runtime/serialize.c
 TEST_LEX_SRCS      = test_lex.c compiler/lexer.c
 TEST_QUICK_SRCS    = test_quick.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c runtime/vm.c runtime/values.c runtime/serialize.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
 TEST_COMPACT_SRCS  = test_compact.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c compiler/strip.c compiler/compact.c runtime/vm.c runtime/values.c runtime/serialize.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
+TEST_SUITE_SRCS    = test_suite.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c runtime/vm.c runtime/values.c runtime/serialize.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
 
 # Command-line compiler (host tool)
 BASCOMP_SRCS   = stub/bascomp.c basBuild.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c compiler/strip.c compiler/obfuscate.c compiler/compact.c runtime/vm.c runtime/values.c runtime/serialize.c ../../../libs/kpunch/libdvx/dvxPrefs.c ../../../libs/kpunch/libdvx/dvxResource.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
@@ -92,11 +94,15 @@ SYSTEMDIR   = ../../../../bin/system
 
 all: $(RT_TARGET) $(RT_TARGETDIR)/basrt.dep $(STUB_TARGET) $(APP_TARGET) $(BASCOMP_TARGET) $(SYSTEMDIR)/BASCOMP.EXE
 
-tests: $(TEST_COMPILER) $(TEST_VM) $(TEST_LEX) $(TEST_QUICK) $(TEST_COMPACT)
+tests: $(TEST_COMPILER) $(TEST_VM) $(TEST_LEX) $(TEST_QUICK) $(TEST_COMPACT) $(TEST_SUITE)
+	$(TEST_SUITE)
 
 $(TEST_COMPILER): $(TEST_COMPILER_SRCS) | $(HOSTDIR)
 	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(TEST_COMPILER_SRCS) -lm
 
+$(TEST_SUITE): $(TEST_SUITE_SRCS) | $(HOSTDIR)
+	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(TEST_SUITE_SRCS) -lm
+
 $(TEST_VM): $(TEST_VM_SRCS) | $(HOSTDIR)
 	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(TEST_VM_SRCS) -lm
 
@@ -152,7 +158,10 @@ $(APP_TARGET): $(COMP_OBJS) $(APP_OBJS) $(STUB_TARGET) dvxbasic.res | $(APPDIR)
 $(OBJDIR)/codegen.o: compiler/codegen.c compiler/codegen.h compiler/symtab.h compiler/opcodes.h runtime/values.h | $(OBJDIR)
 	$(CC) $(CFLAGS) -c -o $@ $<
 
-$(OBJDIR)/formrt.o: formrt/formrt.c formrt/formrt.h compiler/opcodes.h runtime/vm.h | $(OBJDIR)
+$(OBJDIR)/formrt.o: formrt/formrt.c formrt/formrt.h formrt/frmParser.h compiler/opcodes.h runtime/vm.h | $(OBJDIR)
+	$(CC) $(CFLAGS) -c -o $@ $<
+
+$(OBJDIR)/frmParser.o: formrt/frmParser.c formrt/frmParser.h | $(OBJDIR)
 	$(CC) $(CFLAGS) -c -o $@ $<
 
 $(OBJDIR)/serialize.o: runtime/serialize.c runtime/serialize.h runtime/vm.h | $(OBJDIR)
@@ -173,7 +182,7 @@ $(OBJDIR)/basBuild.o: basBuild.c basBuild.h basRes.h | $(OBJDIR)
 $(OBJDIR)/basstub.o: stub/basstub.c runtime/vm.h runtime/serialize.h formrt/formrt.h | $(OBJDIR)
 	$(CC) $(CFLAGS) -c -o $@ $<
 
-$(OBJDIR)/ideDesigner.o: ide/ideDesigner.c ide/ideDesigner.h | $(OBJDIR)
+$(OBJDIR)/ideDesigner.o: ide/ideDesigner.c ide/ideDesigner.h formrt/frmParser.h | $(OBJDIR)
 	$(CC) $(CFLAGS) -c -o $@ $<
 
 $(OBJDIR)/ideMenuEditor.o: ide/ideMenuEditor.c ide/ideMenuEditor.h ide/ideDesigner.h | $(OBJDIR)
@@ -185,7 +194,7 @@ $(OBJDIR)/ideMain.o: ide/ideMain.c ide/ideDesigner.h ide/ideMenuEditor.h ide/ide
 $(OBJDIR)/ideProject.o: ide/ideProject.c ide/ideProject.h | $(OBJDIR)
 	$(CC) $(CFLAGS) -c -o $@ $<
 
-$(OBJDIR)/ideProperties.o: ide/ideProperties.c ide/ideProperties.h ide/ideDesigner.h | $(OBJDIR)
+$(OBJDIR)/ideProperties.o: ide/ideProperties.c ide/ideProperties.h ide/ideDesigner.h formrt/frmParser.h | $(OBJDIR)
 	$(CC) $(CFLAGS) -c -o $@ $<
 
 $(OBJDIR)/ideToolbox.o: ide/ideToolbox.c ide/ideToolbox.h ide/ideDesigner.h | $(OBJDIR)
diff --git a/src/apps/kpunch/dvxbasic/basRes.h b/src/apps/kpunch/dvxbasic/basRes.h
index aaf1f6a..3d30ae8 100644
--- a/src/apps/kpunch/dvxbasic/basRes.h
+++ b/src/apps/kpunch/dvxbasic/basRes.h
@@ -46,10 +46,11 @@
 #define BAS_INI_KEY_HELPFILE     "HelpFile"
 
 // [Settings] / [Modules] / [Forms] sections of a .dbp file
-#define BAS_INI_SECTION_SETTINGS "Settings"
-#define BAS_INI_KEY_STARTUPFORM  "StartupForm"
-#define BAS_INI_SECTION_MODULES  "Modules"
-#define BAS_INI_SECTION_FORMS    "Forms"
+#define BAS_INI_SECTION_SETTINGS    "Settings"
+#define BAS_INI_KEY_STARTUPFORM     "StartupForm"
+#define BAS_INI_KEY_OPTIONEXPLICIT  "OptionExplicit"
+#define BAS_INI_SECTION_MODULES     "Modules"
+#define BAS_INI_SECTION_FORMS       "Forms"
 
 // ------------------------------------------------------------
 // Resource names inside a compiled .app DXE
diff --git a/src/apps/kpunch/dvxbasic/compiler/codegen.c b/src/apps/kpunch/dvxbasic/compiler/codegen.c
index 3046d53..55659f2 100644
--- a/src/apps/kpunch/dvxbasic/compiler/codegen.c
+++ b/src/apps/kpunch/dvxbasic/compiler/codegen.c
@@ -208,12 +208,40 @@ BasModuleT *basCodeGenBuildModuleWithProcs(BasCodeGenT *cg, void *symtab) {
     // Count SUB/FUNCTION entries
     int32_t procCount = 0;
 
+    // Count globals that need runtime type init (currently: STRING).
+    // This list survives stripping, unlike debugVars.
+    int32_t globalInitCount = 0;
+
     for (int32_t i = 0; i < tab->count; i++) {
         BasSymbolT *s = tab->symbols[i];
 
         if ((s->kind == SYM_SUB || s->kind == SYM_FUNCTION) && s->isDefined && !s->isExtern) {
             procCount++;
         }
+
+        if (s->scope == SCOPE_GLOBAL && s->kind == SYM_VARIABLE && s->dataType == BAS_TYPE_STRING && !s->isArray) {
+            globalInitCount++;
+        }
+    }
+
+    if (globalInitCount > 0) {
+        mod->globalInits = (BasGlobalInitT *)malloc(globalInitCount * sizeof(BasGlobalInitT));
+
+        if (mod->globalInits) {
+            int32_t gi = 0;
+
+            for (int32_t i = 0; i < tab->count; i++) {
+                BasSymbolT *s = tab->symbols[i];
+
+                if (s->scope == SCOPE_GLOBAL && s->kind == SYM_VARIABLE && s->dataType == BAS_TYPE_STRING && !s->isArray) {
+                    mod->globalInits[gi].index    = s->index;
+                    mod->globalInits[gi].dataType = s->dataType;
+                    gi++;
+                }
+            }
+
+            mod->globalInitCount = gi;
+        }
     }
 
     if (procCount == 0) {
@@ -235,6 +263,8 @@ BasModuleT *basCodeGenBuildModuleWithProcs(BasCodeGenT *cg, void *symtab) {
             BasProcEntryT *p = &mod->procs[idx++];
             strncpy(p->name, s->name, BAS_MAX_PROC_NAME - 1);
             p->name[BAS_MAX_PROC_NAME - 1] = '\0';
+            strncpy(p->formName, s->formName, BAS_MAX_PROC_NAME - 1);
+            p->formName[BAS_MAX_PROC_NAME - 1] = '\0';
             p->codeAddr   = s->codeAddr;
             p->paramCount = s->paramCount;
             p->localCount = s->localCount;
diff --git a/src/apps/kpunch/dvxbasic/compiler/compact.c b/src/apps/kpunch/dvxbasic/compiler/compact.c
index c857114..c8c9f26 100644
--- a/src/apps/kpunch/dvxbasic/compiler/compact.c
+++ b/src/apps/kpunch/dvxbasic/compiler/compact.c
@@ -169,6 +169,19 @@ int32_t basCompactBytecode(BasModuleT *mod) {
                 break;
             }
 
+            case OP_FOR_INIT: {
+                // [uint16 varIdx] [uint8 scope] [int16 skipOffset]
+                int16_t oldOff = readI16LE(oldCode + oldPc + 4);
+
+                if (!remapRelI16(newCode, newPc, 4, oldOff,
+                                 oldPc + 6, newPc + 6,
+                                 remap, oldCodeLen, newCodeLen, false)) {
+                    ok = false;
+                }
+
+                break;
+            }
+
             case OP_ON_ERROR: {
                 int16_t oldOff = readI16LE(oldCode + oldPc + 1);
 
@@ -421,7 +434,7 @@ static int32_t opOperandSize(uint8_t op) {
         case OP_RGB:
         case OP_GET_RED:         case OP_GET_GREEN:    case OP_GET_BLUE:
         case OP_STR_VAL:         case OP_STR_STRF:     case OP_STR_HEX:
-        case OP_STR_STRING:
+        case OP_STR_STRING:      case OP_STR_OCT:      case OP_CONV_BOOL:
         case OP_MATH_TIMER:      case OP_DATE_STR:     case OP_TIME_STR:
         case OP_SLEEP:           case OP_ENVIRON:
         case OP_READ_DATA:       case OP_RESTORE:
@@ -444,6 +457,7 @@ static int32_t opOperandSize(uint8_t op) {
             return 0;
 
         case OP_LOAD_ARRAY:      case OP_STORE_ARRAY:
+        case OP_PUSH_ARR_ADDR:
         case OP_PRINT_SPC:       case OP_FILE_OPEN:
         case OP_CALL_METHOD:     case OP_SHOW_FORM:
         case OP_LBOUND:          case OP_UBOUND:
@@ -466,13 +480,13 @@ static int32_t opOperandSize(uint8_t op) {
             return 2;
 
         case OP_STORE_ARRAY_FIELD:
-        case OP_FOR_INIT:
             return 3;
 
         case OP_PUSH_INT32:      case OP_PUSH_FLT32:
         case OP_CALL:
             return 4;
 
+        case OP_FOR_INIT:
         case OP_FOR_NEXT:
             return 5;
 
diff --git a/src/apps/kpunch/dvxbasic/compiler/lexer.c b/src/apps/kpunch/dvxbasic/compiler/lexer.c
index 35bdc88..29eff57 100644
--- a/src/apps/kpunch/dvxbasic/compiler/lexer.c
+++ b/src/apps/kpunch/dvxbasic/compiler/lexer.c
@@ -338,10 +338,16 @@ BasTokenTypeE basLexerNext(BasLexerT *lex) {
         return lex->token.type;
     }
 
-    // Hex literal (&H...)
-    if (c == '&' && upperChar(peekNext(lex)) == 'H') {
-        lex->token.type = tokenizeHexLiteral(lex);
-        return lex->token.type;
+    // Numeric-base literals: &H hex, &O octal, &B binary.  &B is an
+    // extension beyond classic QBASIC; it's convenient for bitmask
+    // work in the widget/graphics code.
+    if (c == '&') {
+        char n = upperChar(peekNext(lex));
+
+        if (n == 'H' || n == 'O' || n == 'B') {
+            lex->token.type = tokenizeHexLiteral(lex);
+            return lex->token.type;
+        }
     }
 
     // Identifier or keyword
@@ -621,30 +627,56 @@ static void skipWhitespace(BasLexerT *lex) {
 
 
 static BasTokenTypeE tokenizeHexLiteral(BasLexerT *lex) {
-    advance(lex);  // skip &
-    advance(lex);  // skip H
+    advance(lex);                       // skip &
+    char base = upperChar(peek(lex));
+    advance(lex);                       // skip H/O/B
 
-    int32_t idx = 0;
+    int32_t shift;
+    int32_t maxDigit;
+
+    if (base == 'O') {
+        shift    = 3;
+        maxDigit = 7;
+    } else if (base == 'B') {
+        shift    = 1;
+        maxDigit = 1;
+    } else {
+        shift    = 4;
+        maxDigit = 15;
+    }
+
+    int32_t idx   = 0;
     int32_t value = 0;
 
-    while (!atEnd(lex) && isxdigit((unsigned char)peek(lex))) {
-        char c = advance(lex);
+    for (;;) {
+        if (atEnd(lex)) {
+            break;
+        }
+
+        char c = peek(lex);
+        int32_t digit;
+
+        if (c >= '0' && c <= '9') {
+            digit = c - '0';
+        } else if (shift == 4 && c >= 'A' && c <= 'F') {
+            digit = c - 'A' + 10;
+        } else if (shift == 4 && c >= 'a' && c <= 'f') {
+            digit = c - 'a' + 10;
+        } else {
+            break;
+        }
+
+        if (digit > maxDigit) {
+            break;
+        }
+
+        advance(lex);
 
         if (idx < BAS_MAX_TOKEN_LEN - 1) {
             lex->token.text[idx++] = c;
         }
 
-        int32_t digit;
-
-        if (c >= '0' && c <= '9') {
-            digit = c - '0';
-        } else if (c >= 'A' && c <= 'F') {
-            digit = c - 'A' + 10;
-        } else {
-            digit = c - 'a' + 10;
-        }
-
-        value = (value << 4) | digit;
+        value = (value << shift) | digit;
     }
 
     lex->token.text[idx] = '\0';
diff --git a/src/apps/kpunch/dvxbasic/compiler/obfuscate.c b/src/apps/kpunch/dvxbasic/compiler/obfuscate.c
index 984896e..905295e 100644
--- a/src/apps/kpunch/dvxbasic/compiler/obfuscate.c
+++ b/src/apps/kpunch/dvxbasic/compiler/obfuscate.c
@@ -566,6 +566,16 @@ static void rewriteModuleProcs(BasModuleT *mod, const NameMapT *map) {
             continue;
         }
 
+        // Remap the owning form name (used at runtime to bind form-scope
+        // variables).  The form itself gets renamed by the same pass.
+        if (proc->formName[0]) {
+            const char *mappedForm = nameMapLookup(map, proc->formName);
+
+            if (mappedForm) {
+                snprintf(proc->formName, sizeof(proc->formName), "%s", mappedForm);
+            }
+        }
+
         // Find last underscore
         char *underscore = strrchr(proc->name, '_');
 
diff --git a/src/apps/kpunch/dvxbasic/compiler/opcodes.h b/src/apps/kpunch/dvxbasic/compiler/opcodes.h
index 3a27ef6..8c752c5 100644
--- a/src/apps/kpunch/dvxbasic/compiler/opcodes.h
+++ b/src/apps/kpunch/dvxbasic/compiler/opcodes.h
@@ -180,7 +180,7 @@ typedef enum {
 #define OP_GOSUB_RET       0x54  // pop PC from eval stack, jump (GOSUB return)
 #define OP_RET             0x55  // return from subroutine
 #define OP_RET_VAL         0x56  // return from function (value on stack)
-#define OP_FOR_INIT        0x57  // [uint16 varIdx] [uint8 isLocal] init FOR
+#define OP_FOR_INIT        0x57  // [uint16 varIdx] [uint8 scope] [int16 skipOffset] init FOR, skip body if range empty
 #define OP_FOR_NEXT        0x58  // [uint16 varIdx] [uint8 isLocal] [int16 loopTop]
 #define OP_FOR_POP         0x59  // pop top FOR stack entry (for EXIT FOR)
 
@@ -297,6 +297,9 @@ typedef enum {
 #define OP_STR_STRF        0xB1  // STR$(n) -> string
 #define OP_STR_HEX         0xB2  // HEX$(n) -> string
 #define OP_STR_STRING      0xB3  // STRING$(n, char) -> string
+#define OP_STR_OCT         0xF3  // OCT$(n) -> string
+#define OP_CONV_BOOL       0xF4  // CBOOL(n) -> -1 (true) or 0 (false)
+#define OP_PUSH_ARR_ADDR   0xF5  // [uint8 dims] pop dims indices, pop array ref, push REF to element
 
 // ============================================================
 // Extended built-ins
diff --git a/src/apps/kpunch/dvxbasic/compiler/parser.c b/src/apps/kpunch/dvxbasic/compiler/parser.c
index fddc2bb..568417d 100644
--- a/src/apps/kpunch/dvxbasic/compiler/parser.c
+++ b/src/apps/kpunch/dvxbasic/compiler/parser.c
@@ -69,6 +69,7 @@ static const BuiltinFuncT builtinFuncs[] = {
     {"LEN",        OP_STR_LEN,     1, 1, BAS_TYPE_INTEGER},
     {"LTRIM$",     OP_STR_LTRIM,   1, 1, BAS_TYPE_STRING},
     {"MID$",       OP_STR_MID2,    2, 3, BAS_TYPE_STRING},
+    {"OCT$",       OP_STR_OCT,     1, 1, BAS_TYPE_STRING},
     {"RIGHT$",     OP_STR_RIGHT,   2, 2, BAS_TYPE_STRING},
     {"RTRIM$",     OP_STR_RTRIM,   1, 1, BAS_TYPE_STRING},
     {"SPACE$",     OP_STR_SPACE,   1, 1, BAS_TYPE_STRING},
@@ -84,6 +85,7 @@ static const BuiltinFuncT builtinFuncs[] = {
     {"LOF",        OP_FILE_LOF,      1, 1, BAS_TYPE_LONG},
 
     // Conversion functions
+    {"CBOOL",      OP_CONV_BOOL,   1, 1, BAS_TYPE_BOOLEAN},
     {"CDBL",       OP_CONV_INT_FLT, 1, 1, BAS_TYPE_DOUBLE},
     {"CINT",       OP_CONV_FLT_INT, 1, 1, BAS_TYPE_INTEGER},
     {"CLNG",       OP_CONV_INT_LONG, 1, 1, BAS_TYPE_LONG},
@@ -355,6 +357,11 @@ void basParserInit(BasParserT *p, const char *source, int32_t sourceLen) {
 }
 
 
+void basParserSetValidator(BasParserT *p, const BasCtrlValidatorT *v) {
+    p->validator = v;
+}
+
+
 static bool check(BasParserT *p, BasTokenTypeE type) {
     return p->lex.token.type == type;
 }
@@ -501,10 +508,10 @@ static void emitByRefArg(BasParserT *p) {
     strncpy(name, p->lex.token.text, sizeof(name) - 1);
     name[sizeof(name) - 1] = '\0';
 
-    // Look up the symbol -- must be a simple variable (not array, not const)
+    // Look up the symbol -- must be a variable (simple or array)
     BasSymbolT *sym = basSymTabFind(&p->sym, name);
 
-    if (!sym || sym->kind != SYM_VARIABLE || sym->isArray) {
+    if (!sym || sym->kind != SYM_VARIABLE) {
         parseExpression(p);
         return;
     }
@@ -517,6 +524,44 @@ static void emitByRefArg(BasParserT *p) {
 
     advance(p); // consume the identifier
 
+    // Array element as BYREF: `arr(i)` or `arr(i, j)`.  Emit LOAD of
+    // the array ref, then the indices, then OP_PUSH_ARR_ADDR which
+    // produces a BAS_TYPE_REF pointing at the element.  Writes through
+    // that ref in the callee update the actual array element.
+    if (sym->isArray && check(p, TOK_LPAREN)) {
+        advance(p); // consume (
+
+        // Push the array reference
+        emitLoad(p, sym);
+
+        // Parse indices
+        int32_t dims = 0;
+        parseExpression(p);
+        dims++;
+
+        while (match(p, TOK_COMMA)) {
+            parseExpression(p);
+            dims++;
+        }
+
+        expect(p, TOK_RPAREN);
+
+        // If the next token isn't an argument delimiter, this wasn't
+        // a simple `arr(i)` -- rewind and fall back.  (rare; e.g.
+        // `arr(i).field` not supported as BYREF.)
+        if (!check(p, TOK_COMMA) && !check(p, TOK_RPAREN) && !check(p, TOK_NEWLINE) && !check(p, TOK_COLON) && !check(p, TOK_EOF) && !check(p, TOK_ELSE)) {
+            // Too late to rewind cleanly -- we've already emitted code.
+            // Treat this as an error.  Callers can restructure as a
+            // simple variable BYREF or BYVAL.
+            error(p, "Complex BYREF array expression not supported; use a temporary variable");
+            return;
+        }
+
+        basEmit8(&p->cg, OP_PUSH_ARR_ADDR);
+        basEmit8(&p->cg, (uint8_t)dims);
+        return;
+    }
+
     // The token after the identifier must be an argument delimiter
     // (comma, rparen, newline, colon, EOF, ELSE) for this to be a
     // bare variable reference. Anything else (operator, dot, paren)
@@ -1139,14 +1184,24 @@ static void parseAssignOrCall(BasParserT *p) {
         memberName[BAS_MAX_TOKEN_LEN - 1] = '\0';
         advance(p); // consume member name
 
+        // If `name` is a regular variable, the user is dereferencing an
+        // object reference (typically a form or control returned by
+        // CreateForm / CreateControl).  Use the variable's value as the
+        // ref directly instead of treating `name` as a literal control
+        // name.
+        bool isVarRef = (sym != NULL && sym->kind == SYM_VARIABLE);
+
         // Special form methods: Show, Hide
         if (strcasecmp(memberName, "Show") == 0) {
             // name.Show [modal]
-            // Push form name, LOAD_FORM, SHOW_FORM
-            uint16_t nameIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
-            basEmit8(&p->cg, OP_PUSH_STR);
-            basEmitU16(&p->cg, nameIdx);
-            basEmit8(&p->cg, OP_LOAD_FORM);
+            if (isVarRef) {
+                emitLoad(p, sym);
+            } else {
+                uint16_t nameIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
+                basEmit8(&p->cg, OP_PUSH_STR);
+                basEmitU16(&p->cg, nameIdx);
+                basEmit8(&p->cg, OP_LOAD_FORM);
+            }
             uint8_t modal = 0;
             if (check(p, TOK_INT_LIT)) {
                 if (p->lex.token.intVal != 0) {
@@ -1166,10 +1221,14 @@ static void parseAssignOrCall(BasParserT *p) {
         }
 
         if (strcasecmp(memberName, "Hide") == 0) {
-            uint16_t nameIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
-            basEmit8(&p->cg, OP_PUSH_STR);
-            basEmitU16(&p->cg, nameIdx);
-            basEmit8(&p->cg, OP_LOAD_FORM);
+            if (isVarRef) {
+                emitLoad(p, sym);
+            } else {
+                uint16_t nameIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
+                basEmit8(&p->cg, OP_PUSH_STR);
+                basEmitU16(&p->cg, nameIdx);
+                basEmit8(&p->cg, OP_LOAD_FORM);
+            }
             basEmit8(&p->cg, OP_HIDE_FORM);
             return;
         }
@@ -1178,16 +1237,33 @@ static void parseAssignOrCall(BasParserT *p) {
             // Property assignment: CtrlName.Property = expr
             advance(p); // consume =
 
-            // Push ctrl ref: push form ref (NULL = current), push ctrl name, FIND_CTRL
-            basEmit8(&p->cg, OP_PUSH_INT16);
-            basEmit16(&p->cg, 0);
-            BasValueT formNull;
-            memset(&formNull, 0, sizeof(formNull));
-            // Use OP_PUSH_STR for ctrl name, then FIND_CTRL
-            uint16_t ctrlNameIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
-            basEmit8(&p->cg, OP_PUSH_STR);
-            basEmitU16(&p->cg, ctrlNameIdx);
-            basEmit8(&p->cg, OP_FIND_CTRL);
+            // Compile-time validation: if the host provided a validator
+            // (IDE), check that the property exists on the widget type.
+            // Skip when `name` is a variable reference (dynamic ctrl we
+            // can't statically type) or when the ctrl isn't in the map
+            // (e.g. created via CreateControl at runtime).
+            if (!isVarRef && p->validator && p->validator->lookupCtrlType && p->validator->isPropValid) {
+                const char *wgtType = p->validator->lookupCtrlType(p->validator->ctx, name);
+
+                if (wgtType && !p->validator->isPropValid(p->validator->ctx, wgtType, memberName)) {
+                    char buf[BAS_PARSE_ERR_SCRATCH];
+                    snprintf(buf, sizeof(buf), "Unknown property '%s.%s' (type '%s' has no such property)", name, memberName, wgtType);
+                    error(p, buf);
+                    return;
+                }
+            }
+
+            // Push ctrl/form ref
+            if (isVarRef) {
+                emitLoad(p, sym);
+            } else {
+                basEmit8(&p->cg, OP_PUSH_INT16);
+                basEmit16(&p->cg, 0);
+                uint16_t ctrlNameIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
+                basEmit8(&p->cg, OP_PUSH_STR);
+                basEmitU16(&p->cg, ctrlNameIdx);
+                basEmit8(&p->cg, OP_FIND_CTRL);
+            }
 
             // Push property name
             uint16_t propNameIdx = basAddConstant(&p->cg, memberName, (int32_t)strlen(memberName));
@@ -1203,13 +1279,29 @@ static void parseAssignOrCall(BasParserT *p) {
         }
 
         // Method call: CtrlName.Method [args]
-        // Push ctrl ref
-        basEmit8(&p->cg, OP_PUSH_INT16);
-        basEmit16(&p->cg, 0);
-        uint16_t ctrlNameIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
-        basEmit8(&p->cg, OP_PUSH_STR);
-        basEmitU16(&p->cg, ctrlNameIdx);
-        basEmit8(&p->cg, OP_FIND_CTRL);
+        // Same compile-time validation as above, but for methods.
+        if (!isVarRef && p->validator && p->validator->lookupCtrlType && p->validator->isMethodValid) {
+            const char *wgtType = p->validator->lookupCtrlType(p->validator->ctx, name);
+
+            if (wgtType && !p->validator->isMethodValid(p->validator->ctx, wgtType, memberName)) {
+                char buf[BAS_PARSE_ERR_SCRATCH];
+                snprintf(buf, sizeof(buf), "Unknown method '%s.%s' (type '%s' has no such method)", name, memberName, wgtType);
+                error(p, buf);
+                return;
+            }
+        }
+
+        // Push ctrl/form ref
+        if (isVarRef) {
+            emitLoad(p, sym);
+        } else {
+            basEmit8(&p->cg, OP_PUSH_INT16);
+            basEmit16(&p->cg, 0);
+            uint16_t ctrlNameIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
+            basEmit8(&p->cg, OP_PUSH_STR);
+            basEmitU16(&p->cg, ctrlNameIdx);
+            basEmit8(&p->cg, OP_FIND_CTRL);
+        }
 
         // Push method name
         uint16_t methodNameIdx = basAddConstant(&p->cg, memberName, (int32_t)strlen(memberName));
@@ -1412,13 +1504,52 @@ static void parseAssignOrCall(BasParserT *p) {
                 argc++;
             }
         }
-        if (!p->hasError && argc != sym->paramCount) {
+
+        // Determine the minimum acceptable count (ignore trailing optionals).
+        int32_t minArgs = sym->requiredParams;
+        bool    hasOptional = false;
+
+        for (int32_t i = 0; i < sym->paramCount && i < BAS_MAX_PARAMS; i++) {
+            if (sym->paramOptional[i]) {
+                hasOptional = true;
+                break;
+            }
+        }
+
+        if (!hasOptional) {
+            minArgs = sym->paramCount;
+        }
+
+        if (!p->hasError && (argc < minArgs || argc > sym->paramCount)) {
             char buf[BAS_PARSE_ERR_SCRATCH];
-            snprintf(buf, sizeof(buf), "Sub '%s' expects %d arguments, got %d", sym->name, (int)sym->paramCount, (int)argc);
+
+            if (minArgs == sym->paramCount) {
+                snprintf(buf, sizeof(buf), "Sub '%s' expects %d arguments, got %d", sym->name, (int)sym->paramCount, (int)argc);
+            } else {
+                snprintf(buf, sizeof(buf), "Sub '%s' expects %d to %d arguments, got %d", sym->name, (int)minArgs, (int)sym->paramCount, (int)argc);
+            }
+
             error(p, buf);
             return;
         }
 
+        // Pad missing optional arguments with zero-valued defaults so
+        // the callee's OP_CALL receives a full parameter list.
+        while (argc < sym->paramCount) {
+            uint8_t pType = sym->paramTypes[argc];
+
+            if (pType == BAS_TYPE_STRING) {
+                uint16_t idx = basAddConstant(&p->cg, "", 0);
+                basEmit8(&p->cg, OP_PUSH_STR);
+                basEmitU16(&p->cg, idx);
+            } else {
+                basEmit8(&p->cg, OP_PUSH_INT16);
+                basEmit16(&p->cg, 0);
+            }
+
+            argc++;
+        }
+
         // External library SUB: emit OP_CALL_EXTERN
         if (sym->isExtern) {
             basEmit8(&p->cg, OP_CALL_EXTERN);
@@ -2047,9 +2178,13 @@ static void parseDef(BasParserT *p) {
             paramName[BAS_MAX_TOKEN_LEN - 1] = '\0';
             advance(p);
 
-            uint8_t pdt = suffixToType(paramName);
+            uint8_t pdt         = suffixToType(paramName);
+            int32_t pUdtTypeId  = -1;
             if (match(p, TOK_AS)) {
                 pdt = resolveTypeName(p);
+                if (pdt == BAS_TYPE_UDT) {
+                    pUdtTypeId = p->lastUdtTypeId;
+                }
             }
 
             BasSymbolT *paramSym = basSymTabAdd(&p->sym, paramName, SYM_VARIABLE, pdt);
@@ -2060,6 +2195,7 @@ static void parseDef(BasParserT *p) {
             paramSym->scope     = SCOPE_LOCAL;
             paramSym->index     = basSymTabAllocSlot(&p->sym);
             paramSym->isDefined = true;
+            paramSym->udtTypeId = pUdtTypeId;
 
             if (paramCount < BAS_MAX_PARAMS) {
                 paramTypes[paramCount] = pdt;
@@ -2283,6 +2419,14 @@ static void parseDim(BasParserT *p) {
             emitUdtInit(p, udtTypeId);
             emitStore(p, sym);
         }
+    } else if (dt == BAS_TYPE_STRING) {
+        // STRING slots must start as an empty string, not numeric 0.
+        // Arithmetic falls through basValToNumber so numeric defaults
+        // stay harmless, but STRING concat checks actual slot type.
+        uint16_t idx = basAddConstant(&p->cg, "", 0);
+        basEmit8(&p->cg, OP_PUSH_STR);
+        basEmitU16(&p->cg, idx);
+        emitStore(p, sym);
     }
 }
 
@@ -2572,10 +2716,15 @@ static void parseFor(BasParserT *p) {
         basEmit16(&p->cg, 1);
     }
 
-    // Emit FOR_INIT -- sets up the for-loop state in the VM
+    // Emit FOR_INIT -- sets up the for-loop state in the VM.  The
+    // trailing int16 is a forward offset the VM uses to skip the body
+    // when the loop's range is already empty at entry (e.g. FOR i = 10
+    // TO 5).  We patch it after the body is emitted.
     basEmit8(&p->cg, OP_FOR_INIT);
     basEmitU16(&p->cg, (uint16_t)loopVar->index);
     basEmit8(&p->cg, (uint8_t)loopVar->scope);
+    int32_t skipOffsetPos = basCodePos(&p->cg);
+    basEmit16(&p->cg, 0); // placeholder
 
     int32_t loopBody = basCodePos(&p->cg);
 
@@ -2606,6 +2755,12 @@ static void parseFor(BasParserT *p) {
     int16_t backOffset = (int16_t)(loopBody - (basCodePos(&p->cg) + 2));
     basEmit16(&p->cg, backOffset);
 
+    // Patch FOR_INIT's forward skip offset to point past FOR_NEXT.
+    int32_t loopEnd = basCodePos(&p->cg);
+    int16_t skipOffset = (int16_t)(loopEnd - (skipOffsetPos + 2));
+    p->cg.code[skipOffsetPos]     = (uint8_t)(skipOffset & 0xFF);
+    p->cg.code[skipOffsetPos + 1] = (uint8_t)((skipOffset >> 8) & 0xFF);
+
     // Patch all EXIT FOR jumps to here
     exitListPatch(&exitForList, p);
     exitForList = savedExitFor;
@@ -2684,9 +2839,13 @@ static void parseFunction(BasParserT *p) {
             paramName[BAS_MAX_TOKEN_LEN - 1] = '\0';
             advance(p);
 
-            uint8_t pdt = suffixToType(paramName);
+            uint8_t pdt         = suffixToType(paramName);
+            int32_t pUdtTypeId  = -1;
             if (match(p, TOK_AS)) {
                 pdt = resolveTypeName(p);
+                if (pdt == BAS_TYPE_UDT) {
+                    pUdtTypeId = p->lastUdtTypeId;
+                }
             }
 
             BasSymbolT *paramSym = basSymTabAdd(&p->sym, paramName, SYM_VARIABLE, pdt);
@@ -2697,6 +2856,7 @@ static void parseFunction(BasParserT *p) {
             paramSym->scope     = SCOPE_LOCAL;
             paramSym->index     = basSymTabAllocSlot(&p->sym);
             paramSym->isDefined = true;
+            paramSym->udtTypeId = pUdtTypeId;
 
             if (paramCount < BAS_MAX_PARAMS) {
                 paramTypes[paramCount]    = pdt;
@@ -4172,13 +4332,50 @@ static void parsePrimary(BasParserT *p) {
             memberName[BAS_MAX_TOKEN_LEN - 1] = '\0';
             advance(p);
 
-            // Emit: push NULL (current form), push ctrl name, FIND_CTRL
-            basEmit8(&p->cg, OP_PUSH_INT16);
-            basEmit16(&p->cg, 0);
-            uint16_t ctrlNameIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
-            basEmit8(&p->cg, OP_PUSH_STR);
-            basEmitU16(&p->cg, ctrlNameIdx);
-            basEmit8(&p->cg, OP_FIND_CTRL);
+            bool isVarRef2 = (sym != NULL && sym->kind == SYM_VARIABLE);
+
+            // Compile-time validation for expression-context reads /
+            // method returns.  Peek ahead: if '(' follows, memberName
+            // is a method; otherwise it's a property read.  Skip when
+            // the host didn't attach a validator or the ctrl is dynamic.
+            if (!isVarRef2 && p->validator && p->validator->lookupCtrlType) {
+                const char *wgtType = p->validator->lookupCtrlType(p->validator->ctx, name);
+
+                if (wgtType) {
+                    bool isMethodCall = check(p, TOK_LPAREN);
+                    bool valid = true;
+
+                    if (isMethodCall && p->validator->isMethodValid) {
+                        valid = p->validator->isMethodValid(p->validator->ctx, wgtType, memberName);
+                    } else if (!isMethodCall && p->validator->isPropValid) {
+                        valid = p->validator->isPropValid(p->validator->ctx, wgtType, memberName);
+                    }
+
+                    if (!valid) {
+                        char buf[BAS_PARSE_ERR_SCRATCH];
+                        snprintf(buf, sizeof(buf), "Unknown %s '%s.%s' (type '%s' has no such %s)",
+                                 isMethodCall ? "method" : "property",
+                                 name, memberName, wgtType,
+                                 isMethodCall ? "method" : "property");
+                        error(p, buf);
+                        return;
+                    }
+                }
+            }
+
+            // If `name` is a regular variable holding an object reference
+            // (form/control returned by CreateForm/CreateControl), use its
+            // value directly instead of treating `name` as a literal name.
+            if (isVarRef2) {
+                emitLoad(p, sym);
+            } else {
+                basEmit8(&p->cg, OP_PUSH_INT16);
+                basEmit16(&p->cg, 0);
+                uint16_t ctrlNameIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
+                basEmit8(&p->cg, OP_PUSH_STR);
+                basEmitU16(&p->cg, ctrlNameIdx);
+                basEmit8(&p->cg, OP_FIND_CTRL);
+            }
 
             // If followed by '(', this is a method call with args
             if (check(p, TOK_LPAREN)) {
@@ -5583,9 +5780,13 @@ static void parseSub(BasParserT *p) {
             paramName[BAS_MAX_TOKEN_LEN - 1] = '\0';
             advance(p);
 
-            uint8_t pdt = suffixToType(paramName);
+            uint8_t pdt         = suffixToType(paramName);
+            int32_t pUdtTypeId  = -1;
             if (match(p, TOK_AS)) {
                 pdt = resolveTypeName(p);
+                if (pdt == BAS_TYPE_UDT) {
+                    pUdtTypeId = p->lastUdtTypeId;
+                }
             }
 
             BasSymbolT *paramSym = basSymTabAdd(&p->sym, paramName, SYM_VARIABLE, pdt);
@@ -5596,6 +5797,7 @@ static void parseSub(BasParserT *p) {
             paramSym->scope     = SCOPE_LOCAL;
             paramSym->index     = basSymTabAllocSlot(&p->sym);
             paramSym->isDefined = true;
+            paramSym->udtTypeId = pUdtTypeId;
 
             if (paramCount < BAS_MAX_PARAMS) {
                 paramTypes[paramCount]    = pdt;
diff --git a/src/apps/kpunch/dvxbasic/compiler/parser.h b/src/apps/kpunch/dvxbasic/compiler/parser.h
index 8227a0c..b958932 100644
--- a/src/apps/kpunch/dvxbasic/compiler/parser.h
+++ b/src/apps/kpunch/dvxbasic/compiler/parser.h
@@ -49,6 +49,27 @@
 #define BAS_PARSE_ERROR_LEN    1024
 #define BAS_PARSE_ERR_SCRATCH  512
 
+// Optional compile-time validator for CtrlName.Member references.
+// The IDE populates this from the project's .frm files + widget DXE
+// metadata so typos die at compile time instead of at event-click
+// time.  bascomp leaves it NULL (runs on the host, no widget DXEs)
+// and falls back to the runtime error net.  Dynamically-created
+// controls aren't in the map, so lookupCtrlType returns NULL and
+// validation is skipped for those -- no false positives.
+typedef struct {
+    // Return the widget type name for a control declared in a .frm,
+    // or NULL if the control isn't statically known.
+    const char *(*lookupCtrlType)(void *ctx, const char *ctrlName);
+    // Return true if `methodName` is valid for widget type `wgtType`
+    // (or a common method like Refresh/SetFocus).  Called with the
+    // type string returned by lookupCtrlType.
+    bool        (*isMethodValid)(void *ctx, const char *wgtType, const char *methodName);
+    // Return true if `propName` is a valid property on wgtType.
+    bool        (*isPropValid)(void *ctx, const char *wgtType, const char *propName);
+    void         *ctx;
+} BasCtrlValidatorT;
+
+
 typedef struct {
     BasLexerT    lex;
     BasCodeGenT  cg;
@@ -66,6 +87,8 @@ typedef struct {
     // Per-form init block tracking
     int32_t      formInitJmpAddr;    // code position of JMP to patch (-1 = none)
     int32_t      formInitCodeStart;  // code position where init block starts (-1 = none)
+    // Optional compile-time CtrlName.Member validator (IDE-only).
+    const BasCtrlValidatorT *validator;
 } BasParserT;
 
 // ============================================================
@@ -75,6 +98,11 @@ typedef struct {
 // Initialize parser with source text.
 void basParserInit(BasParserT *p, const char *source, int32_t sourceLen);
 
+// Attach an optional compile-time validator for CtrlName.Member
+// references.  The parser borrows the pointer -- caller owns the
+// underlying struct and must keep it alive until basParserFree.
+void basParserSetValidator(BasParserT *p, const BasCtrlValidatorT *v);
+
 // Parse the entire source and generate p-code.
 // Returns true on success, false on error (check p->error).
 bool basParse(BasParserT *p);
diff --git a/src/apps/kpunch/dvxbasic/compiler/symtab.c b/src/apps/kpunch/dvxbasic/compiler/symtab.c
index 284030d..3d3b661 100644
--- a/src/apps/kpunch/dvxbasic/compiler/symtab.c
+++ b/src/apps/kpunch/dvxbasic/compiler/symtab.c
@@ -83,7 +83,13 @@ BasSymbolT *basSymTabAdd(BasSymTabT *tab, const char *name, BasSymKindE kind, ui
     sym->dataType  = dataType;
     sym->isDefined = true;
 
-    if (scope == SCOPE_FORM && tab->formScopeName[0]) {
+    // Record owning form for both SCOPE_FORM vars AND SUBs/FUNCTIONs
+    // declared inside BEGINFORM...ENDFORM.  SUBs stay at SCOPE_GLOBAL
+    // (callable from anywhere) but carry the owning form so the VM can
+    // bind form-scope vars correctly when the SUB is dispatched as an
+    // event handler for a different form's control.
+    if (tab->inFormScope && tab->formScopeName[0] &&
+        (scope == SCOPE_FORM || kind == SYM_SUB || kind == SYM_FUNCTION)) {
         strncpy(sym->formName, tab->formScopeName, BAS_MAX_SYMBOL_NAME - 1);
         sym->formName[BAS_MAX_SYMBOL_NAME - 1] = '\0';
     }
diff --git a/src/apps/kpunch/dvxbasic/formrt/formrt.c b/src/apps/kpunch/dvxbasic/formrt/formrt.c
index 82ea4db..1e457aa 100644
--- a/src/apps/kpunch/dvxbasic/formrt/formrt.c
+++ b/src/apps/kpunch/dvxbasic/formrt/formrt.c
@@ -38,10 +38,12 @@
 #include "ansiTerm/ansiTerm.h"
 #include "dataCtrl/dataCtrl.h"
 #include "dbGrid/dbGrid.h"
+#include "frmParser.h"
 #include "thirdparty/stb_ds_wrap.h"
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -163,6 +165,34 @@ static bool            sShellLoadResolved = false;
 
 static DvxResHandleT *sResHandles[RES_MAX_HANDLES];
 
+
+// ============================================================
+// .frm parsing context (used by frmLoad_* callbacks)
+// ============================================================
+
+typedef struct {
+    char    caption[256];
+    char    name[BAS_MAX_CTRL_NAME];
+    int32_t level;
+    bool    checked;
+    bool    radioCheck;
+    bool    enabled;
+} BasFrmMenuItemT;
+
+
+typedef struct {
+    BasFormRtT       *rt;
+    BasFormT         *form;
+    BasControlT      *current;
+    WidgetT          *parentStack[BAS_MAX_FRM_NESTING];
+    int32_t           nestDepth;
+    bool              containerStack[BAS_MAX_FRM_NESTING];
+    int32_t           containerDepth;
+    BasFrmMenuItemT  *menuItems;   // stb_ds array
+    int32_t           curMenuItemIdx;
+} BasFrmLoadCtxT;
+
+
 // ============================================================
 // Prototypes
 // ============================================================
@@ -195,6 +225,7 @@ static void *basFormRtLoadCfm(BasFormRtT *rt, const uint8_t *data, int32_t dataL
 void *basFormRtLoadForm(void *ctx, const char *formName);
 BasFormT *basFormRtLoadFrm(BasFormRtT *rt, const char *source, int32_t sourceLen);
 int32_t basFormRtMsgBox(void *ctx, const char *message, int32_t flags, const char *title);
+void basFormRtRuntimeError(BasFormRtT *rt, const char *summary, const char *detailFmt, ...);
 void basFormRtRegisterCfm(BasFormRtT *rt, const char *formName, const uint8_t *data, int32_t dataLen);
 void basFormRtRegisterFrm(BasFormRtT *rt, const char *formName, const char *source, int32_t sourceLen);
 void basFormRtRemoveCtrl(void *ctx, void *formRef, const char *ctrlName);
@@ -204,8 +235,10 @@ void basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT
 void basFormRtShowForm(void *ctx, void *formRef, bool modal);
 void basFormRtUnloadForm(void *ctx, void *formRef);
 const char *basInputBox2(const char *title, const char *prompt, const char *defaultText);
+int32_t basInputCancelled(void);
 int32_t basIntInput(const char *title, const char *prompt, int32_t defaultVal, int32_t minVal, int32_t maxVal);
 static const BasProcEntryT *basModuleFindProc(const BasModuleT *mod, const char *name);
+static BasFormT *resolveOwningForm(BasFormRtT *rt, const BasProcEntryT *proc);
 int32_t basPromptSave(const char *title);
 static BasValueT callCommonMethod(BasControlT *ctrl, const char *methodName, BasValueT *args, int32_t argc);
 void CommAttach(int32_t handle, const char *termCtrlName, int32_t channel, int32_t encrypt);
@@ -225,7 +258,16 @@ int32_t CommSend(int32_t handle, const char *data, int32_t channel, int32_t encr
 static int32_t commTermRead(void *ctx, uint8_t *buf, int32_t maxLen);
 static int32_t commTermWrite(void *ctx, const uint8_t *data, int32_t len);
 WidgetT *createWidget(const char *wgtTypeName, WidgetT *parent);
+WidgetT *createWidgetByIface(const WgtIfaceT *iface, const void *api, WidgetT *parent, bool allowData);
 static void fireCtrlEvent(BasFormRtT *rt, BasControlT *ctrl, const char *eventName, const BasValueT *args, int32_t argCount);
+static void frmLoad_onCtrlBegin(void *userData, const char *typeName, const char *name);
+static void frmLoad_onCtrlEnd(void *userData);
+static void frmLoad_onCtrlProp(void *userData, const char *key, const char *value);
+static bool frmLoad_onFormBegin(void *userData, const char *name);
+static void frmLoad_onFormProp(void *userData, const char *key, const char *value);
+static void frmLoad_onMenuBegin(void *userData, const char *name, int32_t level);
+static void frmLoad_onMenuEnd(void *userData);
+static void frmLoad_onMenuProp(void *userData, const char *key, const char *value);
 static BasValueT getCommonProp(BasControlT *ctrl, const char *propName, bool *handled);
 static BasValueT getIfaceProp(const WgtIfaceT *iface, WidgetT *w, const char *propName, bool *handled);
 int32_t HelpCompile(const char *inputFile, const char *outputFile);
@@ -249,8 +291,8 @@ static void onWidgetMouseUp(WidgetT *w, int32_t button, int32_t x, int32_t y);
 static void onWidgetScroll(WidgetT *w, int32_t delta);
 static bool onWidgetValidate(WidgetT *w);
 static int32_t parseFileFilters(const char *filter, FileFilterT **outFilters, char *buf, int32_t bufSize);
-static void parseFrmLine(const char *line, char *key, char *value);
 static void refreshDetailControls(BasFormT *form, BasControlT *masterCtrl);
+const char *resolveTypeName(const char *typeName);
 int32_t ResAddFile(const char *path, const char *name, int32_t type, const char *srcFile);
 int32_t ResAddText(const char *path, const char *name, const char *text);
 void ResClose(int32_t handle);
@@ -258,7 +300,6 @@ int32_t ResCount(int32_t handle);
 int32_t ResExtract(const char *path, const char *name, const char *outFile);
 const char *ResGetText(const char *path, const char *name);
 const char *ResName(int32_t handle, int32_t index);
-static const char *resolveTypeName(const char *typeName);
 int32_t ResOpen(const char *path);
 int32_t ResRemove(const char *path, const char *name);
 int32_t ResSize(int32_t handle, int32_t index);
@@ -298,6 +339,8 @@ int32_t SQLNext(int32_t rs);
 int32_t SQLOpen(const char *path);
 int32_t SQLQuery(int32_t db, const char *sql);
 static void updateBoundControls(BasFormT *form, BasControlT *dataCtrl);
+bool wgtApplyPropFromString(WidgetT *w, const WgtPropDescT *p, const char *val);
+bool wgtPropValueToString(const WidgetT *w, const WgtPropDescT *p, char *out, int32_t outSize);
 static BasValueT zeroValue(void);
 
 int32_t basChoiceDialog(const char *title, const char *prompt, const char *items, int32_t defaultIdx) {
@@ -543,10 +586,23 @@ void basFormRtBindVm(BasFormRtT *rt) {
 
 
 BasValueT basFormRtCallMethod(void *ctx, void *ctrlRef, const char *methodName, BasValueT *args, int32_t argc) {
-    (void)ctx;
+    BasFormRtT *rt = (BasFormRtT *)ctx;
     BasControlT *ctrl = (BasControlT *)ctrlRef;
 
-    if (!ctrl || !ctrl->widget) {
+    if (!ctrl) {
+        basFormRtRuntimeError(rt,
+            "Method call on unknown control",
+            "Method: %s\nControl was not found on any loaded form.",
+            methodName ? methodName : "?");
+        return zeroValue();
+    }
+
+    if (!ctrl->widget) {
+        basFormRtRuntimeError(rt,
+            "Method call on control with no widget",
+            "Method: %s\nControl: %s\nThe control is known but has no attached widget.",
+            methodName ? methodName : "?",
+            ctrl->name);
         return zeroValue();
     }
 
@@ -762,7 +818,7 @@ WidgetT *basFormRtCreateContentBox(WidgetT *root, const char *layout) {
 
 
 void *basFormRtCreateCtrl(void *ctx, void *formRef, const char *typeName, const char *ctrlName) {
-    (void)ctx;
+    BasFormRtT *rt = (BasFormRtT *)ctx;
     BasFormT *form = (BasFormT *)formRef;
 
     if (!form) {
@@ -773,6 +829,12 @@ void *basFormRtCreateCtrl(void *ctx, void *formRef, const char *typeName, const
     const char *wgtTypeName = resolveTypeName(typeName);
 
     if (!wgtTypeName) {
+        basFormRtRuntimeError(rt,
+            "CreateControl: unknown control type",
+            "Requested type: %s\nForm: %s\nControl name: %s",
+            typeName ? typeName : "(null)",
+            form->name,
+            ctrlName ? ctrlName : "(null)");
         return NULL;
     }
 
@@ -780,12 +842,25 @@ void *basFormRtCreateCtrl(void *ctx, void *formRef, const char *typeName, const
     WidgetT *parent = form->contentBox ? form->contentBox : form->root;
 
     if (!parent) {
+        basFormRtRuntimeError(rt,
+            "CreateControl: form has no parent widget",
+            "Form: %s\nType: %s\nControl name: %s",
+            form->name,
+            typeName ? typeName : "(null)",
+            ctrlName ? ctrlName : "(null)");
         return NULL;
     }
 
     WidgetT *widget = createWidget(wgtTypeName, parent);
 
     if (!widget) {
+        basFormRtRuntimeError(rt,
+            "CreateControl: widget creation failed",
+            "Type: %s (resolved from %s)\nForm: %s\nControl name: %s",
+            wgtTypeName,
+            typeName ? typeName : "(null)",
+            form->name,
+            ctrlName ? ctrlName : "(null)");
         return NULL;
     }
 
@@ -799,7 +874,8 @@ void *basFormRtCreateCtrl(void *ctx, void *formRef, const char *typeName, const
     }
 
     ctrl->index  = -1;
-    snprintf(ctrl->name, BAS_MAX_CTRL_NAME, "%s", ctrlName);
+    snprintf(ctrl->name,     BAS_MAX_CTRL_NAME, "%s", ctrlName);
+    snprintf(ctrl->typeName, BAS_MAX_CTRL_NAME, "%s", typeName ? typeName : "");
     ctrl->widget = widget;
     ctrl->form   = form;
     ctrl->iface  = wgtGetIface(wgtTypeName);
@@ -820,16 +896,27 @@ void *basFormRtCreateCtrl(void *ctx, void *formRef, const char *typeName, const
 
 
 void *basFormRtCreateCtrlEx(void *ctx, void *formRef, const char *typeName, const char *ctrlName, void *parentRef) {
-    (void)ctx;
+    BasFormRtT *rt = (BasFormRtT *)ctx;
     BasFormT *form = (BasFormT *)formRef;
 
     if (!form) {
+        basFormRtRuntimeError(rt,
+            "CreateControl: form reference is NULL",
+            "Type: %s\nControl name: %s",
+            typeName ? typeName : "(null)",
+            ctrlName ? ctrlName : "(null)");
         return NULL;
     }
 
     const char *wgtTypeName = resolveTypeName(typeName);
 
     if (!wgtTypeName) {
+        basFormRtRuntimeError(rt,
+            "CreateControl: unknown control type",
+            "Requested type: %s\nForm: %s\nControl name: %s",
+            typeName ? typeName : "(null)",
+            form->name,
+            ctrlName ? ctrlName : "(null)");
         return NULL;
     }
 
@@ -865,7 +952,8 @@ void *basFormRtCreateCtrlEx(void *ctx, void *formRef, const char *typeName, cons
     }
 
     ctrl->index  = -1;
-    snprintf(ctrl->name, BAS_MAX_CTRL_NAME, "%s", ctrlName);
+    snprintf(ctrl->name,     BAS_MAX_CTRL_NAME, "%s", ctrlName);
+    snprintf(ctrl->typeName, BAS_MAX_CTRL_NAME, "%s", typeName ? typeName : "");
     ctrl->widget = widget;
     ctrl->form   = form;
     ctrl->iface  = wgtGetIface(wgtTypeName);
@@ -1068,7 +1156,7 @@ void basFormRtEventLoop(BasFormRtT *rt) {
         return;
     }
 
-    while (rt->ctx->running && arrlen(rt->forms) > 0) {
+    while (rt->ctx->running && arrlen(rt->forms) > 0 && !rt->terminated) {
         if (!dvxUpdate(rt->ctx)) {
             break;
         }
@@ -1080,7 +1168,13 @@ void *basFormRtFindCtrl(void *ctx, void *formRef, const char *ctrlName) {
     BasFormRtT *rt = (BasFormRtT *)ctx;
     BasFormT *form = (BasFormT *)formRef;
 
+    dvxLog("[FC] findCtrl: form=%p '%s' looking for '%s'",
+           (void *)form,
+           form ? form->name : "(null)",
+           ctrlName ? ctrlName : "(null)");
+
     if (!form) {
+        dvxLog("[FC]   NULL FORM");
         return NULL;
     }
 
@@ -1111,11 +1205,41 @@ void *basFormRtFindCtrl(void *ctx, void *formRef, const char *ctrlName) {
         }
     }
 
-    // Search other loaded forms by name (for cross-form property access)
+    // Search across all loaded forms (for cross-form property access).
+    // Dynamic forms created by CreateForm/CreateControl hold controls
+    // that the calling SUB references by name (e.g. a mnuXxx_Click on
+    // the main form sets properties on a control just created on a new
+    // form).  Check form names first, then each form's controls, then
+    // each form's menu items.
     if (rt) {
         for (int32_t i = 0; i < (int32_t)arrlen(rt->forms); i++) {
-            if (strcasecmp(rt->forms[i]->name, ctrlName) == 0) {
-                return &rt->forms[i]->formCtrl;
+            BasFormT *other = rt->forms[i];
+
+            if (other == form) {
+                continue;
+            }
+
+            if (strcasecmp(other->name, ctrlName) == 0) {
+                return &other->formCtrl;
+            }
+
+            for (int32_t j = 0; j < (int32_t)arrlen(other->controls); j++) {
+                if (strcasecmp(other->controls[j]->name, ctrlName) == 0) {
+                    return other->controls[j];
+                }
+            }
+
+            for (int32_t j = 0; j < other->menuIdMapCount; j++) {
+                if (strcasecmp(other->menuIdMap[j].name, ctrlName) == 0) {
+                    if (!other->menuIdMap[j].proxy) {
+                        BasControlT *proxy = (BasControlT *)calloc(1, sizeof(BasControlT));
+                        snprintf(proxy->name, BAS_MAX_CTRL_NAME, "%s", ctrlName);
+                        proxy->form   = other;
+                        proxy->menuId = other->menuIdMap[j].id;
+                        other->menuIdMap[j].proxy = proxy;
+                    }
+                    return other->menuIdMap[j].proxy;
+                }
             }
         }
     }
@@ -1175,9 +1299,14 @@ bool basFormRtFireEventArgs(BasFormRtT *rt, BasFormT *form, const char *ctrlName
     BasValueT *prevVars     = rt->vm->currentFormVars;
     int32_t    prevVarCount = rt->vm->currentFormVarCount;
 
+    // Bind form-scope vars to the SUB's owning form (from BEGINFORM),
+    // not to the control's form.
+    BasFormT *owningForm = resolveOwningForm(rt, proc);
+    BasFormT *varsForm   = owningForm ? owningForm : form;
+
     rt->currentForm = form;
     basVmSetCurrentForm(rt->vm, form);
-    basVmSetCurrentFormVars(rt->vm, form->formVars, form->formVarCount);
+    basVmSetCurrentFormVars(rt->vm, varsForm->formVars, varsForm->formVarCount);
 
     bool ok;
 
@@ -1222,9 +1351,14 @@ static bool basFormRtFireEventWithCancel(BasFormRtT *rt, BasFormT *form, const c
     BasValueT *prevVars     = rt->vm->currentFormVars;
     int32_t    prevVarCount = rt->vm->currentFormVarCount;
 
+    // Bind form-scope vars to the SUB's owning form (from BEGINFORM),
+    // not to the control's form.
+    BasFormT *owningForm = resolveOwningForm(rt, proc);
+    BasFormT *varsForm   = owningForm ? owningForm : form;
+
     rt->currentForm = form;
     basVmSetCurrentForm(rt->vm, form);
-    basVmSetCurrentFormVars(rt->vm, form->formVars, form->formVarCount);
+    basVmSetCurrentFormVars(rt->vm, varsForm->formVars, varsForm->formVarCount);
 
     bool cancelled = false;
 
@@ -1251,10 +1385,14 @@ static bool basFormRtFireEventWithCancel(BasFormRtT *rt, BasFormT *form, const c
 
 
 BasValueT basFormRtGetProp(void *ctx, void *ctrlRef, const char *propName) {
-    (void)ctx;
+    BasFormRtT *rt = (BasFormRtT *)ctx;
     BasControlT *ctrl = (BasControlT *)ctrlRef;
 
     if (!ctrl) {
+        basFormRtRuntimeError(rt,
+            "Read of unknown control",
+            "Property: %s\nControl was not found on any loaded form.",
+            propName ? propName : "?");
         return zeroValue();
     }
 
@@ -1274,10 +1412,18 @@ BasValueT basFormRtGetProp(void *ctx, void *ctrlRef, const char *propName) {
             return basValStringFromC(ctrl->name);
         }
 
+        basFormRtRuntimeError(rt,
+            "Unknown menu property",
+            "Menu: %s\nProperty: %s\nMenu items only expose Checked, Enabled, and Name.",
+            ctrl->name, propName ? propName : "?");
         return zeroValue();
     }
 
     if (!ctrl->widget) {
+        basFormRtRuntimeError(rt,
+            "Property read on control with no widget",
+            "Control: %s\nProperty: %s\nThe control is known but has no attached widget.",
+            ctrl->name, propName ? propName : "?");
         return zeroValue();
     }
 
@@ -1298,6 +1444,10 @@ BasValueT basFormRtGetProp(void *ctx, void *ctrlRef, const char *propName) {
         if (strcasecmp(propName, "Centered") == 0)   { return basValBool(frm->frmCentered); }
         if (strcasecmp(propName, "Layout") == 0)     { return basValStringFromC(frm->frmLayout); }
 
+        basFormRtRuntimeError(rt,
+            "Unknown form property",
+            "Form: %s\nProperty: %s\nValid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout.",
+            frm->name, propName ? propName : "?");
         return zeroValue();
     }
 
@@ -1351,6 +1501,12 @@ BasValueT basFormRtGetProp(void *ctx, void *ctrlRef, const char *propName) {
         }
     }
 
+    basFormRtRuntimeError(rt,
+        "Property not found on control",
+        "Control: %s\nType: %s\nProperty: %s\nThe control has no readable property by that name.",
+        ctrl->name,
+        ctrl->typeName[0] ? ctrl->typeName : "?",
+        propName ? propName : "?");
     return zeroValue();
 }
 
@@ -1359,7 +1515,19 @@ void basFormRtHideForm(void *ctx, void *formRef) {
     BasFormRtT *rt   = (BasFormRtT *)ctx;
     BasFormT   *form = (BasFormT *)formRef;
 
-    if (!form || !form->window) {
+    if (!form) {
+        basFormRtRuntimeError(rt,
+            "Hide on unknown form",
+            "The form reference resolved to NULL; check that the form name exists.",
+            NULL);
+        return;
+    }
+
+    if (!form->window) {
+        basFormRtRuntimeError(rt,
+            "Hide on form with no window",
+            "Form: %s\nThe form has no window attached (was it destroyed already?).",
+            form->name);
         return;
     }
 
@@ -1566,7 +1734,13 @@ void *basFormRtLoadForm(void *ctx, const char *formName) {
     form->formCtrl.widget = root;
     form->formCtrl.form   = form;
 
-    // Allocate per-form variable storage and run init code from module metadata
+    // Allocate per-form variable storage.  Init code runs later:
+    //   - If we were reached recursively from basFormRtLoadFrm (sLoadingFrm
+    //     is true), it runs AFTER parsing populates form->controls, so
+    //     init code can see OutArea et al.  Running it here would fire
+    //     against an empty controls list.
+    //   - Otherwise this is a genuine bare form (no cached .frm); we
+    //     still need to run init to DIM arrays and UDT fields.
     if (rt->module && rt->module->formVarInfo) {
         for (int32_t j = 0; j < rt->module->formVarInfoCount; j++) {
             if (strcasecmp(rt->module->formVarInfo[j].formName, formName) == 0) {
@@ -1577,10 +1751,9 @@ void *basFormRtLoadForm(void *ctx, const char *formName) {
                     form->formVarCount = vc;
                 }
 
-                // Execute per-form init block (DIM arrays, UDT init)
                 int32_t initAddr = rt->module->formVarInfo[j].initCodeAddr;
 
-                if (initAddr >= 0 && rt->vm) {
+                if (!sLoadingFrm && initAddr >= 0 && rt->vm) {
                     basVmSetCurrentForm(rt->vm, form);
                     basVmSetCurrentFormVars(rt->vm, form->formVars, form->formVarCount);
                     basVmCallSub(rt->vm, initAddr);
@@ -1602,363 +1775,31 @@ BasFormT *basFormRtLoadFrm(BasFormRtT *rt, const char *source, int32_t sourceLen
         return NULL;
     }
 
-    BasFormT    *form    = NULL;
-    BasControlT *current = NULL;
-
-    WidgetT *parentStack[BAS_MAX_FRM_NESTING];
-    int32_t  nestDepth = 0;
-
-    // Track Begin/End blocks: true = container (Form/Frame), false = control
-    bool    isContainer[BAS_MAX_FRM_NESTING];
-    int32_t blockDepth = 0;
-
-    // Temporary menu item accumulation
-    typedef struct {
-        char    caption[256];
-        char    name[BAS_MAX_CTRL_NAME];
-        int32_t level;
-        bool    checked;
-        bool    radioCheck;
-        bool    enabled;
-    } TempMenuItemT;
-
-    TempMenuItemT *menuItems    = NULL; // stb_ds array
-    TempMenuItemT *curMenuItem  = NULL;
-    int32_t        menuNestDepth = 0;
-    bool           inMenu        = false;
-
-    const char *pos = source;
-    const char *end = source + sourceLen;
-
-    while (pos < end) {
-        const char *lineStart = pos;
-
-        while (pos < end && *pos != '\n' && *pos != '\r') {
-            pos++;
-        }
-
-        int32_t lineLen = (int32_t)(pos - lineStart);
-
-        if (pos < end && *pos == '\r') {
-            pos++;
-        }
-
-        if (pos < end && *pos == '\n') {
-            pos++;
-        }
-
-        char line[BAS_MAX_FRM_LINE_LEN];
-
-        if (lineLen >= BAS_MAX_FRM_LINE_LEN) {
-            lineLen = BAS_MAX_FRM_LINE_LEN - 1;
-        }
-
-        memcpy(line, lineStart, lineLen);
-        line[lineLen] = '\0';
-
-        const char *trimmed = dvxSkipWs(line);
-
-        if (*trimmed == '\0' || *trimmed == '\'') {
-            continue;
-        }
-
-        // "VERSION DVX x.xx" (native) or "VERSION x.xx" (VB import)
-        if (strncasecmp(trimmed, "VERSION ", 8) == 0) {
-            const char *ver = trimmed + 8;
-
-            if (strncasecmp(ver, "DVX ", 4) != 0) {
-                double vbVer = atof(ver);
-
-                if (vbVer > 2.0) {
-                    return NULL; // VB4+ form, not compatible
-                }
-            }
-
-            continue;
-        }
-
-        // "Begin TypeName CtrlName"
-        if (strncasecmp(trimmed, "Begin ", 6) == 0) {
-            const char *rest = trimmed + 6;
-            char  typeName[BAS_MAX_CTRL_NAME];
-            char  ctrlName[BAS_MAX_CTRL_NAME];
-
-            int32_t ti = 0;
-
-            while (*rest && *rest != ' ' && *rest != '\t' && ti < BAS_MAX_CTRL_NAME - 1) {
-                typeName[ti++] = *rest++;
-            }
-
-            typeName[ti] = '\0';
-
-            rest = dvxSkipWs(rest);
-
-            int32_t ci = 0;
-
-            while (*rest && *rest != ' ' && *rest != '\t' && *rest != '\r' && *rest != '\n' && ci < BAS_MAX_CTRL_NAME - 1) {
-                ctrlName[ci++] = *rest++;
-            }
-
-            ctrlName[ci] = '\0';
-
-            if (strcasecmp(typeName, "Form") == 0) {
-                form = (BasFormT *)basFormRtLoadForm(rt, ctrlName);
-
-                if (!form) {
-                    return NULL;
-                }
-
-                // contentBox may already be set from basFormRtLoadForm.
-                // It gets replaced at the End block after Layout is known.
-                nestDepth = 1;
-                parentStack[0] = form->contentBox;
-                current   = NULL;
-
-                if (blockDepth < BAS_MAX_FRM_NESTING) {
-                    isContainer[blockDepth++] = true;
-                }
-            } else if (strcasecmp(typeName, "Menu") == 0 && form) {
-                TempMenuItemT mi;
-                memset(&mi, 0, sizeof(mi));
-                snprintf(mi.name, BAS_MAX_CTRL_NAME, "%s", ctrlName);
-                mi.level   = menuNestDepth;
-                mi.enabled = true;
-                arrput(menuItems, mi);
-                curMenuItem = &menuItems[arrlen(menuItems) - 1];
-                current     = NULL;
-                menuNestDepth++;
-                inMenu = true;
-
-                if (blockDepth < BAS_MAX_FRM_NESTING) {
-                    isContainer[blockDepth++] = false;
-                }
-
-                continue;
-            } else if (form && nestDepth > 0) {
-                // Create the content box on first control if not yet done
-                if (!form->contentBox && form->root) {
-                    form->contentBox = basFormRtCreateContentBox(form->root, form->frmLayout);
-                    parentStack[0] = form->contentBox;
-                }
-                WidgetT *parent = parentStack[nestDepth - 1];
-
-                const char *wgtTypeName = resolveTypeName(typeName);
-
-                if (!wgtTypeName) {
-                    continue;
-                }
-
-                WidgetT *widget = createWidget(wgtTypeName, parent);
-
-                if (!widget) {
-                    continue;
-                }
-
-                wgtSetName(widget, ctrlName);
-
-                {
-                    BasControlT *ctrlEntry = (BasControlT *)calloc(1, sizeof(BasControlT));
-
-                    if (!ctrlEntry) {
-                        continue;
-                    }
-
-                    snprintf(ctrlEntry->name, BAS_MAX_CTRL_NAME, "%s", ctrlName);
-                    snprintf(ctrlEntry->typeName, BAS_MAX_CTRL_NAME, "%s", typeName);
-                    ctrlEntry->index  = -1;
-                    ctrlEntry->widget = widget;
-                    ctrlEntry->form   = form;
-                    ctrlEntry->iface  = wgtGetIface(wgtTypeName);
-                    arrput(form->controls, ctrlEntry);
-                
-
-                    current = ctrlEntry;
-                    widget->userData   = current;
-                    widget->onClick     = onWidgetClick;
-                    widget->onDblClick  = onWidgetDblClick;
-                    widget->onChange     = onWidgetChange;
-                    widget->onFocus     = onWidgetFocus;
-                    widget->onBlur      = onWidgetBlur;
-                    widget->onValidate  = onWidgetValidate;
-                    widget->onKeyPress  = onWidgetKeyPress;
-                    widget->onKeyDown   = onWidgetKeyDown;
-                    widget->onKeyUp     = onWidgetKeyUp;
-                    widget->onMouseDown = onWidgetMouseDown;
-                    widget->onMouseUp   = onWidgetMouseUp;
-                    widget->onMouseMove = onWidgetMouseMove;
-                    widget->onScroll    = onWidgetScroll;
-                }
-
-                // Track block type for End handling
-                const WgtIfaceT *ctrlIface = wgtGetIface(wgtTypeName);
-                bool isCtrlContainer = ctrlIface && ctrlIface->isContainer;
-
-                if (isCtrlContainer && nestDepth < BAS_MAX_FRM_NESTING) {
-                    // Push the widget for now; the Layout property hasn't
-                    // been parsed yet. It will be applied when we see it
-                    // via containerLayout[] below.
-                    parentStack[nestDepth++] = widget;
-
-                    if (blockDepth < BAS_MAX_FRM_NESTING) {
-                        isContainer[blockDepth++] = true;
-                    }
-                } else {
-                    if (blockDepth < BAS_MAX_FRM_NESTING) {
-                        isContainer[blockDepth++] = false;
-                    }
-                }
-            }
-
-            continue;
-        }
-
-        // "End"
-        if (strcasecmp(trimmed, "End") == 0) {
-            if (inMenu) {
-                menuNestDepth--;
-                curMenuItem = NULL;
-
-                if (menuNestDepth <= 0) {
-                    menuNestDepth = 0;
-                    inMenu = false;
-                }
-
-                if (blockDepth > 0) {
-                    blockDepth--;
-                }
-
-                continue;
-            }
-
-            if (blockDepth > 0) {
-                blockDepth--;
-
-                // Only decrement parent nesting for containers (Form/Frame)
-                if (isContainer[blockDepth] && nestDepth > 0) {
-                    nestDepth--;
-                }
-            }
-
-            current = NULL;
-            continue;
-        }
-
-        // Property assignment: Key = Value
-        char key[BAS_MAX_CTRL_NAME];
-        char value[BAS_MAX_FRM_LINE_LEN];
-        parseFrmLine(trimmed, key, value);
-
-        if (key[0] == '\0' || !form) {
-            continue;
-        }
-
-        if (curMenuItem) {
-            // Menu item properties
-            char *text = value;
-
-            if (text[0] == '"') {
-                text++;
-                int32_t len = (int32_t)strlen(text);
-
-                if (len > 0 && text[len - 1] == '"') {
-                    text[len - 1] = '\0';
-                }
-            }
-
-            if (strcasecmp(key, "Caption") == 0)      { snprintf(curMenuItem->caption, sizeof(curMenuItem->caption), "%s", text); }
-            else if (strcasecmp(key, "Checked") == 0)    { curMenuItem->checked = (strcasecmp(text, "True") == 0 || strcasecmp(text, "-1") == 0); }
-            else if (strcasecmp(key, "RadioCheck") == 0) { curMenuItem->radioCheck = (strcasecmp(text, "True") == 0 || strcasecmp(text, "-1") == 0); }
-            else if (strcasecmp(key, "Enabled") == 0)  { curMenuItem->enabled = (strcasecmp(text, "True") == 0 || strcasecmp(text, "-1") == 0 || strcasecmp(text, "False") != 0); }
-        } else if (current) {
-            // Control array index is stored on the struct, not as a widget property
-            if (strcasecmp(key, "Index") == 0) {
-                current->index = atoi(value);
-                continue;
-            }
-
-            // HelpTopic is stored on BasControlT, not on the widget
-            if (strcasecmp(key, "HelpTopic") == 0) {
-                char *text = value;
-                if (text[0] == '"') { text++; }
-                int32_t tlen = (int32_t)strlen(text);
-                if (tlen > 0 && text[tlen - 1] == '"') { text[tlen - 1] = '\0'; }
-                snprintf(current->helpTopic, sizeof(current->helpTopic), "%s", text);
-                continue;
-            }
-
-            // Layout property on a container: replace the parentStack entry
-            // with a layout box inside the container widget.
-            // NOTE: only do this for non-default layouts (HBox, WrapBox).
-            // VBox is the default for Frame, so no wrapper needed.
-            if (strcasecmp(key, "Layout") == 0 && current && current->widget && nestDepth > 0) {
-                char *text = value;
-                if (text[0] == '"') { text++; }
-                int32_t tlen = (int32_t)strlen(text);
-                if (tlen > 0 && text[tlen - 1] == '"') { text[tlen - 1] = '\0'; }
-                if (strcasecmp(text, "VBox") != 0) {
-                    parentStack[nestDepth - 1] = basFormRtCreateContentBox(current->widget, text);
-                }
-                continue;
-            }
-
-            BasValueT val;
-
-            if (value[0] == '"') {
-                int32_t vlen = (int32_t)strlen(value);
-
-                if (vlen >= 2 && value[vlen - 1] == '"') {
-                    value[vlen - 1] = '\0';
-                }
-
-                val = basValStringFromC(value + 1);
-            } else if (strcasecmp(value, "True") == 0) {
-                val = basValBool(true);
-            } else if (strcasecmp(value, "False") == 0) {
-                val = basValBool(false);
-            } else {
-                val = basValLong(atoi(value));
-            }
-
-            basFormRtSetProp(rt, current, key, val);
-            basValRelease(&val);
-        } else if (nestDepth > 0) {
-            // Form-level property -- strip quotes from string values
-            char *text = value;
-
-            if (text[0] == '"') {
-                text++;
-                int32_t len = (int32_t)strlen(text);
-
-                if (len > 0 && text[len - 1] == '"') {
-                    text[len - 1] = '\0';
-                }
-            }
-
-            if (strcasecmp(key, "Caption") == 0) {
-                dvxSetTitle(rt->ctx, form->window, text);
-            } else if (strcasecmp(key, "Width") == 0) {
-                form->frmWidth = atoi(value);
-            } else if (strcasecmp(key, "Height") == 0) {
-                form->frmHeight = atoi(value);
-            } else if (strcasecmp(key, "Left") == 0) {
-                form->frmLeft = atoi(value);
-            } else if (strcasecmp(key, "Top") == 0) {
-                form->frmTop = atoi(value);
-            } else if (strcasecmp(key, "Resizable") == 0) {
-                form->frmResizable = (strcasecmp(text, "True") == 0);
-                form->frmHasResizable = true;
-            } else if (strcasecmp(key, "Centered") == 0) {
-                form->frmCentered = (strcasecmp(text, "True") == 0);
-            } else if (strcasecmp(key, "AutoSize") == 0) {
-                form->frmAutoSize = (strcasecmp(text, "True") == 0);
-            } else if (strcasecmp(key, "Layout") == 0) {
-                snprintf(form->frmLayout, sizeof(form->frmLayout), "%s", text);
-            } else if (strcasecmp(key, "HelpTopic") == 0) {
-                snprintf(form->helpTopic, sizeof(form->helpTopic), "%s", text);
-            }
-        }
+    BasFrmLoadCtxT ctx;
+    memset(&ctx, 0, sizeof(ctx));
+    ctx.rt             = rt;
+    ctx.curMenuItemIdx = -1;
+
+    FrmParserCbsT cbs;
+    memset(&cbs, 0, sizeof(cbs));
+    cbs.userData     = &ctx;
+    cbs.onFormBegin  = frmLoad_onFormBegin;
+    cbs.onFormProp   = frmLoad_onFormProp;
+    cbs.onMenuBegin  = frmLoad_onMenuBegin;
+    cbs.onMenuEnd    = frmLoad_onMenuEnd;
+    cbs.onMenuProp   = frmLoad_onMenuProp;
+    cbs.onCtrlBegin  = frmLoad_onCtrlBegin;
+    cbs.onCtrlEnd    = frmLoad_onCtrlEnd;
+    cbs.onCtrlProp   = frmLoad_onCtrlProp;
+
+    if (!frmParse(source, sourceLen, &cbs)) {
+        arrfree(ctx.menuItems);
+        return NULL;
     }
 
+    BasFormT        *form      = ctx.form;
+    BasFrmMenuItemT *menuItems = ctx.menuItems;
+
     // Apply accumulated form-level properties
     if (form) {
         // Ensure content box exists even if form has no controls
@@ -1978,8 +1819,8 @@ BasFormT *basFormRtLoadFrm(BasFormRtT *rt, const char *source, int32_t sourceLen
                 memset(menuStack, 0, sizeof(menuStack));
 
                 for (int32_t i = 0; i < menuCount; i++) {
-                    TempMenuItemT *mi = &menuItems[i];
-                    bool isSep        = (mi->caption[0] == '-' && (mi->caption[1] == '\0' || mi->caption[1] == '-'));
+                    BasFrmMenuItemT *mi = &menuItems[i];
+                    bool isSep          = (mi->caption[0] == '-' && (mi->caption[1] == '\0' || mi->caption[1] == '-'));
                     bool isSubParent  = (i + 1 < menuCount && menuItems[i + 1].level > mi->level);
 
                     if (mi->level == 0) {
@@ -2177,6 +2018,73 @@ int32_t basFormRtMsgBox(void *ctx, const char *message, int32_t flags, const cha
 }
 
 
+// Report a non-recoverable runtime error.  Logs to DVX.LOG with the
+// summary on one line and the details indented beneath, then shows a
+// modal MessageBox with the details, then halts the VM so execution
+// stops instead of limping on with a corrupted state.
+//
+// Used for errors that indicate a bug in the BASIC program: unknown
+// control type, missing control reference, unknown method, etc.
+// Silent no-ops here would hide these failures from the developer.
+void basFormRtRuntimeError(BasFormRtT *rt, const char *summary, const char *detailFmt, ...) {
+    char    details[512];
+    va_list ap;
+
+    va_start(ap, detailFmt);
+    vsnprintf(details, sizeof(details), detailFmt, ap);
+    va_end(ap);
+
+    // Log with a blank line and indent so it stands out among normal
+    // log traffic.
+    dvxLog("");
+    dvxLog("BASIC RUNTIME ERROR: %s", summary ? summary : "(no summary)");
+
+    // Split details on newlines and indent each one.
+    const char *p = details;
+
+    while (*p) {
+        const char *nl = strchr(p, '\n');
+        int32_t     len = nl ? (int32_t)(nl - p) : (int32_t)strlen(p);
+        char        line[256];
+
+        if (len >= (int32_t)sizeof(line)) {
+            len = sizeof(line) - 1;
+        }
+
+        memcpy(line, p, len);
+        line[len] = '\0';
+        dvxLog("    %s", line);
+
+        if (!nl) {
+            break;
+        }
+
+        p = nl + 1;
+    }
+
+    if (rt && rt->ctx) {
+        char boxMsg[640];
+        snprintf(boxMsg, sizeof(boxMsg), "%s\n\n%s",
+                 summary ? summary : "Runtime error",
+                 details);
+        dvxMessageBox(rt->ctx, "BASIC Runtime Error", boxMsg, MB_OK | MB_ICONERROR);
+    }
+
+    // Halt the VM so execution stops here rather than stumbling forward
+    // with a corrupted state.  Without this, a single missing control
+    // can trigger a cascade of follow-on errors as subsequent property
+    // accesses and method calls fail too.  The `terminated` flag tells
+    // basFormRtEventLoop to exit at its next pump -- otherwise the main
+    // window would stay open with a dead VM behind it.
+    if (rt) {
+        if (rt->vm) {
+            rt->vm->running = false;
+        }
+        rt->terminated = true;
+    }
+}
+
+
 void basFormRtRegisterCfm(BasFormRtT *rt, const char *formName, const uint8_t *data, int32_t dataLen) {
     if (!rt || !formName || !data || dataLen <= 0) {
         return;
@@ -2276,6 +2184,13 @@ void basFormRtSetEvent(void *ctx, void *ctrlRef, const char *eventName, const ch
     (void)ctx;
     BasControlT *ctrl = (BasControlT *)ctrlRef;
 
+    dvxLog("[SE] SetEvent: ctrl=%p '%s' type='%s' event='%s' -> handler='%s'",
+           (void *)ctrl,
+           ctrl ? ctrl->name : "(null)",
+           ctrl ? ctrl->typeName : "(null)",
+           eventName ? eventName : "(null)",
+           handlerName ? handlerName : "(null)");
+
     if (!ctrl || !eventName || !handlerName) {
         return;
     }
@@ -2293,6 +2208,9 @@ void basFormRtSetEvent(void *ctx, void *ctrlRef, const char *eventName, const ch
         BasEventOverrideT *ov = &ctrl->eventOverrides[ctrl->eventOverrideCount++];
         snprintf(ov->eventName, BAS_MAX_CTRL_NAME, "%s", eventName);
         snprintf(ov->handlerName, BAS_MAX_CTRL_NAME, "%s", handlerName);
+        dvxLog("[SE]   added (count=%d)", (int)ctrl->eventOverrideCount);
+    } else {
+        dvxLog("[SE]   NO SLOT (count=%d)", (int)ctrl->eventOverrideCount);
     }
 }
 
@@ -2302,6 +2220,10 @@ void basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT
     BasControlT *ctrl = (BasControlT *)ctrlRef;
 
     if (!ctrl) {
+        basFormRtRuntimeError(rt,
+            "Assignment to unknown control",
+            "Property: %s\nControl was not found on any loaded form.",
+            propName ? propName : "?");
         return;
     }
 
@@ -2319,10 +2241,18 @@ void basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT
             return;
         }
 
+        basFormRtRuntimeError(rt,
+            "Unknown menu property",
+            "Menu: %s\nProperty: %s\nMenu items only support Checked and Enabled.",
+            ctrl->name, propName ? propName : "?");
         return;
     }
 
     if (!ctrl->widget) {
+        basFormRtRuntimeError(rt,
+            "Property set on control with no widget",
+            "Control: %s\nProperty: %s\nThe control is known but has no attached widget.",
+            ctrl->name, propName ? propName : "?");
         return;
     }
 
@@ -2399,6 +2329,10 @@ void basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT
             return;
         }
 
+        basFormRtRuntimeError(rt,
+            "Unknown form property",
+            "Form: %s\nProperty: %s\nValid: Caption, Visible, Width, Height, Left, Top, Resizable, AutoSize, Centered.",
+            frm->name, propName ? propName : "?");
         return;
     }
 
@@ -2411,6 +2345,7 @@ void basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT
     // strdup their text internally).
     if (strcasecmp(propName, "Caption") == 0 || strcasecmp(propName, "Text") == 0) {
         BasStringT *s = basValFormatString(value);
+        dvxLog("[SP] setProp Caption: ctrl='%s' text='%s'", ctrl->name, s ? s->data : "(null)");
         wgtSetText(ctrl->widget, s->data);
         basStringUnref(s);
         return;
@@ -2445,6 +2380,16 @@ void basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT
             return;
         }
     }
+
+    // None of the common, form-local, or iface property paths matched.
+    // A silent no-op used to hide typos; now it's a loud runtime error
+    // that mirrors the method-not-found diagnostic.
+    basFormRtRuntimeError(rt,
+        "Property not found on control",
+        "Control: %s\nType: %s\nProperty: %s\nThe control has no writable property by that name.",
+        ctrl->name,
+        ctrl->typeName[0] ? ctrl->typeName : "?",
+        propName ? propName : "?");
 }
 
 
@@ -2452,7 +2397,19 @@ void basFormRtShowForm(void *ctx, void *formRef, bool modal) {
     BasFormRtT *rt   = (BasFormRtT *)ctx;
     BasFormT   *form = (BasFormT *)formRef;
 
-    if (!form || !form->window) {
+    if (!form) {
+        basFormRtRuntimeError(rt,
+            "Show on unknown form",
+            "The form reference resolved to NULL; check that the form name exists.",
+            NULL);
+        return;
+    }
+
+    if (!form->window) {
+        basFormRtRuntimeError(rt,
+            "Show on form with no window",
+            "Form: %s\nThe form has no window attached (was it destroyed already?).",
+            form->name);
         return;
     }
 
@@ -2530,8 +2487,16 @@ void basFormRtUnloadForm(void *ctx, void *formRef) {
 }
 
 
+// Tracks whether the most recent basInputBox2 call was cancelled.
+// BASIC callers query this via basInputCancelled so they can tell the
+// difference between the user hitting Cancel and the user clicking OK
+// on an empty field.
+static bool sLastInputBoxCancelled = false;
+
+
 const char *basInputBox2(const char *title, const char *prompt, const char *defaultText) {
     if (!sFormRt) {
+        sLastInputBoxCancelled = true;
         return "";
     }
 
@@ -2539,13 +2504,20 @@ const char *basInputBox2(const char *title, const char *prompt, const char *defa
     buf[0] = '\0';
 
     if (dvxInputBox(sFormRt->ctx, title, prompt, defaultText, buf, sizeof(buf))) {
+        sLastInputBoxCancelled = false;
         return buf;
     }
 
+    sLastInputBoxCancelled = true;
     return "";
 }
 
 
+int32_t basInputCancelled(void) {
+    return sLastInputBoxCancelled ? -1 : 0;
+}
+
+
 int32_t basIntInput(const char *title, const char *prompt, int32_t defaultVal, int32_t minVal, int32_t maxVal) {
     if (!sFormRt) {
         return defaultVal;
@@ -2572,6 +2544,33 @@ static const BasProcEntryT *basModuleFindProc(const BasModuleT *mod, const char
 }
 
 
+// Find the loaded form whose .frm declared this SUB.  Returns NULL if
+// the SUB is module-global (no BEGINFORM scope) or the owning form
+// isn't currently loaded -- callers should fall back to ctrl->form.
+static BasFormT *resolveOwningForm(BasFormRtT *rt, const BasProcEntryT *proc) {
+    if (!rt || !proc) {
+        return NULL;
+    }
+
+    dvxLog("[OF] resolveOwningForm: proc='%s' formName='%s' formCount=%d",
+           proc->name, proc->formName, (int)arrlen(rt->forms));
+
+    if (!proc->formName[0]) {
+        return NULL;
+    }
+
+    for (int32_t i = 0; i < (int32_t)arrlen(rt->forms); i++) {
+        dvxLog("[OF]   form[%d] name='%s' varCount=%d",
+               (int)i, rt->forms[i]->name, (int)rt->forms[i]->formVarCount);
+        if (strcasecmp(rt->forms[i]->name, proc->formName) == 0) {
+            return rt->forms[i];
+        }
+    }
+
+    return NULL;
+}
+
+
 int32_t basPromptSave(const char *title) {
     if (!sFormRt) {
         return 2;
@@ -2582,9 +2581,6 @@ int32_t basPromptSave(const char *title) {
 
 
 static BasValueT callCommonMethod(BasControlT *ctrl, const char *methodName, BasValueT *args, int32_t argc) {
-    (void)args;
-    (void)argc;
-
     if (strcasecmp(methodName, "SetFocus") == 0) {
         wgtSetFocused(ctrl->widget);
         return zeroValue();
@@ -2595,6 +2591,35 @@ static BasValueT callCommonMethod(BasControlT *ctrl, const char *methodName, Bas
         return zeroValue();
     }
 
+    if (strcasecmp(methodName, "SetReadOnly") == 0) {
+        bool ro = (argc >= 1) ? (basValToNumber(args[0]) != 0.0) : true;
+        wgtSetReadOnly(ctrl->widget, ro);
+        return zeroValue();
+    }
+
+    if (strcasecmp(methodName, "SetEnabled") == 0) {
+        bool en = (argc >= 1) ? (basValToNumber(args[0]) != 0.0) : true;
+        wgtSetEnabled(ctrl->widget, en);
+        return zeroValue();
+    }
+
+    if (strcasecmp(methodName, "SetVisible") == 0) {
+        bool vis = (argc >= 1) ? (basValToNumber(args[0]) != 0.0) : true;
+        wgtSetVisible(ctrl->widget, vis);
+        return zeroValue();
+    }
+
+    // Unknown method: raise a loud runtime error (visible MessageBox +
+    // DVX.LOG entry).  Silent no-ops here used to hide typos in method
+    // names.  This is a runtime safety net -- the parser should reject
+    // unknown methods at compile time once bascomp can see widget
+    // interface metadata.
+    const char *ctrlName = ctrl ? ctrl->name : "?";
+    basFormRtRuntimeError(sFormRt,
+        "Method not found on control",
+        "Method: %s\nControl: %s\nThe control has no method by that name.",
+        methodName, ctrlName);
+
     return zeroValue();
 }
 
@@ -2911,8 +2936,16 @@ WidgetT *createWidget(const char *wgtTypeName, WidgetT *parent) {
         return NULL;
     }
 
-    // Determine creation signature from the widget interface descriptor.
     const WgtIfaceT *iface = wgtGetIface(wgtTypeName);
+    return createWidgetByIface(iface, api, parent, true);
+}
+
+
+WidgetT *createWidgetByIface(const WgtIfaceT *iface, const void *api, WidgetT *parent, bool allowData) {
+    if (!api) {
+        return NULL;
+    }
+
     uint8_t sig = iface ? iface->createSig : WGT_CREATE_PARENT;
 
     typedef WidgetT *(*CreateParentFnT)(WidgetT *);
@@ -2949,6 +2982,10 @@ WidgetT *createWidget(const char *wgtTypeName, WidgetT *parent) {
             return fn(parent, (bool)iface->createArgs[0]);
         }
         case WGT_CREATE_PARENT_DATA: {
+            if (!allowData) {
+                // Design-time: can't auto-create Image/ImageButton without pixel data
+                return NULL;
+            }
             // create(parent, NULL, 0, 0, 0) -- empty widget, load content later via properties
             typedef WidgetT *(*CreateDataFnT)(WidgetT *, uint8_t *, int32_t, int32_t, int32_t);
             CreateDataFnT fn = *(CreateDataFnT *)api;
@@ -2963,6 +3000,29 @@ WidgetT *createWidget(const char *wgtTypeName, WidgetT *parent) {
 
 
 static void fireCtrlEvent(BasFormRtT *rt, BasControlT *ctrl, const char *eventName, const BasValueT *args, int32_t argCount) {
+    dvxLog("[FE] fireCtrlEvent: ctrl=%p '%s' type='%s' event='%s' eventFiring=%d overrideCount=%d",
+           (void *)ctrl,
+           ctrl ? ctrl->name : "(null)",
+           ctrl ? ctrl->typeName : "(null)",
+           eventName ? eventName : "(null)",
+           ctrl ? (int)ctrl->eventFiring : -1,
+           ctrl ? (int)ctrl->eventOverrideCount : -1);
+
+    // Re-entrancy guard: reject only same-event re-entry (runSubLoop
+    // pumps events during long handlers; without this, a lingering
+    // mouse-up can re-deliver Click on top of itself).  But allow
+    // different events on the same control (e.g. LostFocus while Click
+    // is still running) -- those aren't re-entrant, they're interleaved.
+    if (ctrl->eventFiring) {
+        bool sameEvent = (ctrl->firingEventName[0] &&
+                          strcasecmp(ctrl->firingEventName, eventName) == 0);
+
+        if (sameEvent) {
+            dvxLog("  SUPPRESSED (same event already firing)");
+            return;
+        }
+    }
+
     // Build final argument list (prepend Index for control arrays).  The
     // array is sized to the actual arg count so any number of event args
     // passes through intact; the +1 is for the Index prefix.
@@ -2989,18 +3049,27 @@ static void fireCtrlEvent(BasFormRtT *rt, BasControlT *ctrl, const char *eventNa
 
     // Check for event override (SetEvent)
     for (int32_t i = 0; i < ctrl->eventOverrideCount; i++) {
+        dvxLog("[FE]   override[%d] event='%s' handler='%s'",
+               (int)i,
+               ctrl->eventOverrides[i].eventName,
+               ctrl->eventOverrides[i].handlerName);
         if (strcasecmp(ctrl->eventOverrides[i].eventName, eventName) != 0) {
             continue;
         }
 
         // Override found: call the named SUB directly
         if (!rt->vm || !rt->module) {
+            dvxLog("[FE]   NO VM/MODULE");
             return;
         }
 
         const BasProcEntryT *proc = basModuleFindProc(rt->module, ctrl->eventOverrides[i].handlerName);
 
         if (!proc || proc->isFunction) {
+            dvxLog("[FE]   PROC NOT FOUND or IS FUNCTION: '%s' proc=%p isFunction=%d",
+                   ctrl->eventOverrides[i].handlerName,
+                   (void *)proc,
+                   proc ? (int)proc->isFunction : -1);
             free(allArgs);
             return;
         }
@@ -3009,14 +3078,49 @@ static void fireCtrlEvent(BasFormRtT *rt, BasControlT *ctrl, const char *eventNa
         BasValueT *prevVars     = rt->vm->currentFormVars;
         int32_t    prevVarCount = rt->vm->currentFormVarCount;
 
+        // The SUB's form-scope variables live on its OWNING form (the
+        // .frm that declared it inside BEGINFORM...ENDFORM), not on
+        // the control's form -- those can differ when a SUB from form
+        // A is wired as an event handler for a control on form B.
+        BasFormT *owningForm = resolveOwningForm(rt, proc);
+        BasFormT *varsForm   = owningForm ? owningForm : ctrl->form;
+
         rt->currentForm = ctrl->form;
         basVmSetCurrentForm(rt->vm, ctrl->form);
-        basVmSetCurrentFormVars(rt->vm, ctrl->form->formVars, ctrl->form->formVarCount);
+        basVmSetCurrentFormVars(rt->vm, varsForm->formVars, varsForm->formVarCount);
 
+        // Only claim the guard if we're the outer event.  Nested
+        // different-event dispatches (e.g. LostFocus while Click is
+        // running) must NOT touch eventFiring/firingEventName, or
+        // the outer event's same-event suppression would be broken.
+        bool claimedGuard = !ctrl->eventFiring;
+
+        if (claimedGuard) {
+            ctrl->eventFiring = true;
+            snprintf(ctrl->firingEventName, sizeof(ctrl->firingEventName), "%s", eventName ? eventName : "");
+        }
+
+        dvxLog("  -> calling SUB '%s' at addr=%d", ctrl->eventOverrides[i].handlerName, (int)proc->codeAddr);
+
+        rt->vm->errorMsg[0] = '\0';
+        rt->vm->errorNumber = 0;
+
+        bool subOk;
         if (finalArgCount > 0 && finalArgs) {
-            basVmCallSubWithArgs(rt->vm, proc->codeAddr, finalArgs, finalArgCount);
+            subOk = basVmCallSubWithArgs(rt->vm, proc->codeAddr, finalArgs, finalArgCount);
         } else {
-            basVmCallSub(rt->vm, proc->codeAddr);
+            subOk = basVmCallSub(rt->vm, proc->codeAddr);
+        }
+
+        dvxLog("  <- SUB '%s' returned ok=%d errNum=%d errMsg='%s'",
+               ctrl->eventOverrides[i].handlerName,
+               (int)subOk,
+               (int)rt->vm->errorNumber,
+               rt->vm->errorMsg);
+
+        if (claimedGuard) {
+            ctrl->eventFiring        = false;
+            ctrl->firingEventName[0] = '\0';
         }
 
         rt->currentForm = prevForm;
@@ -3026,17 +3130,277 @@ static void fireCtrlEvent(BasFormRtT *rt, BasControlT *ctrl, const char *eventNa
         return;
     }
 
-    // No override -- fall back to naming convention (CtrlName_EventName)
+    // No override -- fall back to naming convention (CtrlName_EventName).
+    dvxLog("[FE]   no override matched, trying naming convention '%s_%s'",
+           ctrl->name, eventName);
+
+    bool claimedGuard = !ctrl->eventFiring;
+
+    if (claimedGuard) {
+        ctrl->eventFiring = true;
+        snprintf(ctrl->firingEventName, sizeof(ctrl->firingEventName), "%s", eventName ? eventName : "");
+    }
+
+    bool fired;
+
     if (finalArgCount > 0 && finalArgs) {
-        basFormRtFireEventArgs(rt, ctrl->form, ctrl->name, eventName, finalArgs, finalArgCount);
+        fired = basFormRtFireEventArgs(rt, ctrl->form, ctrl->name, eventName, finalArgs, finalArgCount);
     } else {
-        basFormRtFireEvent(rt, ctrl->form, ctrl->name, eventName);
+        fired = basFormRtFireEvent(rt, ctrl->form, ctrl->name, eventName);
+    }
+
+    dvxLog("[FE]   naming-convention result: fired=%d", (int)fired);
+
+    if (claimedGuard) {
+        ctrl->eventFiring        = false;
+        ctrl->firingEventName[0] = '\0';
     }
 
     free(allArgs);
 }
 
 
+// ============================================================
+// frmParser callbacks for basFormRtLoadFrm
+// ============================================================
+
+static void frmLoad_onCtrlBegin(void *userData, const char *typeName, const char *name) {
+    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;
+
+    if (!ctx->form || ctx->nestDepth <= 0) {
+        return;
+    }
+
+    // Lazy-create the content box once we know the form layout.
+    if (!ctx->form->contentBox && ctx->form->root) {
+        ctx->form->contentBox = basFormRtCreateContentBox(ctx->form->root, ctx->form->frmLayout);
+        ctx->parentStack[0]   = ctx->form->contentBox;
+    }
+
+    const char *wgtTypeName = resolveTypeName(typeName);
+    bool        isCtrlContainer = false;
+
+    if (wgtTypeName) {
+        WidgetT *parent = ctx->parentStack[ctx->nestDepth - 1];
+        WidgetT *widget = createWidget(wgtTypeName, parent);
+
+        if (widget) {
+            wgtSetName(widget, name);
+
+            BasControlT *ctrlEntry = (BasControlT *)calloc(1, sizeof(BasControlT));
+
+            if (ctrlEntry) {
+                snprintf(ctrlEntry->name, BAS_MAX_CTRL_NAME, "%s", name);
+                snprintf(ctrlEntry->typeName, BAS_MAX_CTRL_NAME, "%s", typeName);
+                ctrlEntry->index  = -1;
+                ctrlEntry->widget = widget;
+                ctrlEntry->form   = ctx->form;
+                ctrlEntry->iface  = wgtGetIface(wgtTypeName);
+                arrput(ctx->form->controls, ctrlEntry);
+
+                ctx->current = ctrlEntry;
+
+                widget->userData    = ctrlEntry;
+                widget->onClick     = onWidgetClick;
+                widget->onDblClick  = onWidgetDblClick;
+                widget->onChange    = onWidgetChange;
+                widget->onFocus     = onWidgetFocus;
+                widget->onBlur      = onWidgetBlur;
+                widget->onValidate  = onWidgetValidate;
+                widget->onKeyPress  = onWidgetKeyPress;
+                widget->onKeyDown   = onWidgetKeyDown;
+                widget->onKeyUp     = onWidgetKeyUp;
+                widget->onMouseDown = onWidgetMouseDown;
+                widget->onMouseUp   = onWidgetMouseUp;
+                widget->onMouseMove = onWidgetMouseMove;
+                widget->onScroll    = onWidgetScroll;
+            }
+
+            const WgtIfaceT *ctrlIface = wgtGetIface(wgtTypeName);
+            isCtrlContainer = ctrlIface && ctrlIface->isContainer;
+
+            if (isCtrlContainer && ctx->nestDepth < BAS_MAX_FRM_NESTING) {
+                ctx->parentStack[ctx->nestDepth++] = widget;
+            }
+        }
+    }
+
+    if (ctx->containerDepth < BAS_MAX_FRM_NESTING) {
+        ctx->containerStack[ctx->containerDepth++] = isCtrlContainer;
+    }
+}
+
+
+static void frmLoad_onCtrlEnd(void *userData) {
+    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;
+
+    if (ctx->containerDepth > 0) {
+        ctx->containerDepth--;
+
+        if (ctx->containerStack[ctx->containerDepth] && ctx->nestDepth > 0) {
+            ctx->nestDepth--;
+        }
+    }
+
+    ctx->current = NULL;
+}
+
+
+static void frmLoad_onCtrlProp(void *userData, const char *key, const char *value) {
+    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;
+
+    if (!ctx->current) {
+        return;
+    }
+
+    // Control array index is stored on the struct, not as a widget property
+    if (strcasecmp(key, "Index") == 0) {
+        ctx->current->index = atoi(value);
+        return;
+    }
+
+    char scratch[BAS_MAX_FRM_LINE_LEN];
+    snprintf(scratch, sizeof(scratch), "%s", value);
+    frmStripQuotes(scratch);
+
+    if (strcasecmp(key, "HelpTopic") == 0) {
+        snprintf(ctx->current->helpTopic, sizeof(ctx->current->helpTopic), "%s", scratch);
+        return;
+    }
+
+    // Layout property on a container: replace the parentStack entry
+    // with a layout box inside the container widget.  VBox is the
+    // default for Frame, so no wrapper needed.
+    if (strcasecmp(key, "Layout") == 0 && ctx->current->widget && ctx->nestDepth > 0) {
+        if (strcasecmp(scratch, "VBox") != 0) {
+            ctx->parentStack[ctx->nestDepth - 1] = basFormRtCreateContentBox(ctx->current->widget, scratch);
+        }
+        return;
+    }
+
+    BasValueT val;
+
+    if (value[0] == '"') {
+        val = basValStringFromC(scratch);
+    } else if (strcasecmp(value, "True") == 0) {
+        val = basValBool(true);
+    } else if (strcasecmp(value, "False") == 0) {
+        val = basValBool(false);
+    } else {
+        val = basValLong(atoi(value));
+    }
+
+    basFormRtSetProp(ctx->rt, ctx->current, key, val);
+    basValRelease(&val);
+}
+
+
+static bool frmLoad_onFormBegin(void *userData, const char *name) {
+    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;
+
+    ctx->form = (BasFormT *)basFormRtLoadForm(ctx->rt, name);
+
+    if (!ctx->form) {
+        return false;
+    }
+
+    // contentBox may already be set from basFormRtLoadForm.  It gets
+    // replaced after Layout is known via onCtrlBegin's lazy creation.
+    ctx->nestDepth     = 1;
+    ctx->parentStack[0] = ctx->form->contentBox;
+    ctx->current        = NULL;
+    return true;
+}
+
+
+static void frmLoad_onFormProp(void *userData, const char *key, const char *value) {
+    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;
+
+    if (!ctx->form) {
+        return;
+    }
+
+    char text[BAS_MAX_FRM_LINE_LEN];
+    snprintf(text, sizeof(text), "%s", value);
+    frmStripQuotes(text);
+
+    if (strcasecmp(key, "Caption") == 0) {
+        dvxSetTitle(ctx->rt->ctx, ctx->form->window, text);
+    } else if (strcasecmp(key, "Width") == 0) {
+        ctx->form->frmWidth = atoi(value);
+    } else if (strcasecmp(key, "Height") == 0) {
+        ctx->form->frmHeight = atoi(value);
+    } else if (strcasecmp(key, "Left") == 0) {
+        ctx->form->frmLeft = atoi(value);
+    } else if (strcasecmp(key, "Top") == 0) {
+        ctx->form->frmTop = atoi(value);
+    } else if (strcasecmp(key, "Resizable") == 0) {
+        ctx->form->frmResizable = frmParseBool(text);
+        ctx->form->frmHasResizable = true;
+    } else if (strcasecmp(key, "Centered") == 0) {
+        ctx->form->frmCentered = frmParseBool(text);
+    } else if (strcasecmp(key, "AutoSize") == 0) {
+        ctx->form->frmAutoSize = frmParseBool(text);
+    } else if (strcasecmp(key, "Layout") == 0) {
+        snprintf(ctx->form->frmLayout, sizeof(ctx->form->frmLayout), "%s", text);
+    } else if (strcasecmp(key, "HelpTopic") == 0) {
+        snprintf(ctx->form->helpTopic, sizeof(ctx->form->helpTopic), "%s", text);
+    }
+}
+
+
+static void frmLoad_onMenuBegin(void *userData, const char *name, int32_t level) {
+    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;
+
+    if (!ctx->form) {
+        return;
+    }
+
+    BasFrmMenuItemT mi;
+    memset(&mi, 0, sizeof(mi));
+    snprintf(mi.name, BAS_MAX_CTRL_NAME, "%s", name);
+    mi.level   = level;
+    mi.enabled = true;
+    arrput(ctx->menuItems, mi);
+    ctx->curMenuItemIdx = (int32_t)arrlen(ctx->menuItems) - 1;
+    ctx->current        = NULL;
+}
+
+
+static void frmLoad_onMenuEnd(void *userData) {
+    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;
+
+    ctx->curMenuItemIdx = -1;
+}
+
+
+static void frmLoad_onMenuProp(void *userData, const char *key, const char *value) {
+    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;
+
+    if (ctx->curMenuItemIdx < 0 || ctx->curMenuItemIdx >= (int32_t)arrlen(ctx->menuItems)) {
+        return;
+    }
+
+    // Resolve the pointer fresh each use -- arrput on nested menus
+    // may have reallocated the array.
+    BasFrmMenuItemT *mip = &ctx->menuItems[ctx->curMenuItemIdx];
+    char             text[BAS_MAX_FRM_LINE_LEN];
+    snprintf(text, sizeof(text), "%s", value);
+    frmStripQuotes(text);
+
+    if (strcasecmp(key, "Caption") == 0) {
+        snprintf(mip->caption, sizeof(mip->caption), "%s", text);
+    } else if (strcasecmp(key, "Checked") == 0) {
+        mip->checked = frmParseBool(text);
+    } else if (strcasecmp(key, "RadioCheck") == 0) {
+        mip->radioCheck = frmParseBool(text);
+    } else if (strcasecmp(key, "Enabled") == 0) {
+        // Default-true: anything not "False" enables the item.
+        mip->enabled = (strcasecmp(text, "False") != 0);
+    }
+}
+
+
 static BasValueT getCommonProp(BasControlT *ctrl, const char *propName, bool *handled) {
     *handled = true;
 
@@ -3363,6 +3727,11 @@ static void onWidgetBlur(WidgetT *w) {
 static void onWidgetChange(WidgetT *w) {
     BasControlT *ctrl = (BasControlT *)w->userData;
 
+    dvxLog("[OC] onWidgetChange: w=%p ctrl=%p name='%s' type='%s'",
+           (void *)w, (void *)ctrl,
+           ctrl ? ctrl->name : "(null)",
+           ctrl ? ctrl->typeName : "(null)");
+
     if (!ctrl || !ctrl->form || !ctrl->form->vm) {
         return;
     }
@@ -3388,6 +3757,13 @@ static void onWidgetChange(WidgetT *w) {
 static void onWidgetClick(WidgetT *w) {
     BasControlT *ctrl = (BasControlT *)w->userData;
 
+    dvxLog("[OK] onWidgetClick: w=%p ctrl=%p name='%s' type='%s' form=%p vm=%p",
+           (void *)w, (void *)ctrl,
+           ctrl ? ctrl->name : "(null)",
+           ctrl ? ctrl->typeName : "(null)",
+           ctrl ? (void *)ctrl->form : NULL,
+           ctrl && ctrl->form ? (void *)ctrl->form->vm : NULL);
+
     if (!ctrl || !ctrl->form || !ctrl->form->vm) {
         return;
     }
@@ -3681,42 +4057,6 @@ static int32_t parseFileFilters(const char *filter, FileFilterT **outFilters, ch
 }
 
 
-static void parseFrmLine(const char *line, char *key, char *value) {
-    key[0]   = '\0';
-    value[0] = '\0';
-
-    line = dvxSkipWs(line);
-
-    int32_t ki = 0;
-
-    while (*line && *line != '=' && *line != ' ' && *line != '\t' && ki < BAS_MAX_CTRL_NAME - 1) {
-        key[ki++] = *line++;
-    }
-
-    key[ki] = '\0';
-
-    line = dvxSkipWs(line);
-
-    if (*line == '=') {
-        line++;
-    }
-
-    line = dvxSkipWs(line);
-
-    int32_t vi = 0;
-
-    while (*line && *line != '\r' && *line != '\n' && vi < BAS_MAX_FRM_LINE_LEN - 1) {
-        value[vi++] = *line++;
-    }
-
-    value[vi] = '\0';
-
-    while (vi > 0 && (value[vi - 1] == ' ' || value[vi - 1] == '\t')) {
-        value[--vi] = '\0';
-    }
-}
-
-
 // refreshDetailControls -- cascade master-detail: refresh detail Data controls
 static void refreshDetailControls(BasFormT *form, BasControlT *masterCtrl) {
     if (!masterCtrl->widget) {
@@ -3907,7 +4247,7 @@ const char *ResName(int32_t handle, int32_t index) {
 // widget type name. First tries wgtFindByBasName for VB names
 // like "CommandButton", then falls back to direct widget name.
 
-static const char *resolveTypeName(const char *typeName) {
+const char *resolveTypeName(const char *typeName) {
     // Try VB name first (e.g. "CommandButton" -> "button")
     const char *wgtName = wgtFindByBasName(typeName);
 
@@ -4238,6 +4578,20 @@ static bool setCommonProp(BasControlT *ctrl, const char *propName, BasValueT val
         int32_t w = (int32_t)basValToNumber(value);
         ctrl->widget->minW = wgtPixels(w);
         wgtInvalidate(ctrl->widget);
+        // Widgets with a pixel-buffer (Canvas, Image) need their
+        // internal buffer resized, not just the layout dimension.
+        // basFormRtLoadFrm does this once at .frm parse time; for
+        // dynamic controls (CreateControl + Width/Height properties)
+        // we run the equivalent here.
+        if (ctrl->iface && ctrl->widget->minW > 0 && ctrl->widget->minH > 0) {
+            for (int32_t m = 0; m < ctrl->iface->methodCount; m++) {
+                if (strcasecmp(ctrl->iface->methods[m].name, "Resize") == 0 &&
+                    ctrl->iface->methods[m].sig == WGT_SIG_INT_INT) {
+                    ((void (*)(WidgetT *, int32_t, int32_t))ctrl->iface->methods[m].fn)(ctrl->widget, ctrl->widget->minW, ctrl->widget->minH);
+                    break;
+                }
+            }
+        }
         return true;
     }
 
@@ -4245,6 +4599,15 @@ static bool setCommonProp(BasControlT *ctrl, const char *propName, BasValueT val
         int32_t h = (int32_t)basValToNumber(value);
         ctrl->widget->minH = wgtPixels(h);
         wgtInvalidate(ctrl->widget);
+        if (ctrl->iface && ctrl->widget->minW > 0 && ctrl->widget->minH > 0) {
+            for (int32_t m = 0; m < ctrl->iface->methodCount; m++) {
+                if (strcasecmp(ctrl->iface->methods[m].name, "Resize") == 0 &&
+                    ctrl->iface->methods[m].sig == WGT_SIG_INT_INT) {
+                    ((void (*)(WidgetT *, int32_t, int32_t))ctrl->iface->methods[m].fn)(ctrl->widget, ctrl->widget->minW, ctrl->widget->minH);
+                    break;
+                }
+            }
+        }
         return true;
     }
 
@@ -4278,6 +4641,11 @@ static bool setCommonProp(BasControlT *ctrl, const char *propName, BasValueT val
         return true;
     }
 
+    if (strcasecmp(propName, "ReadOnly") == 0) {
+        wgtSetReadOnly(ctrl->widget, basValIsTruthy(value));
+        return true;
+    }
+
     if (strcasecmp(propName, "TabIndex") == 0) {
         return true;
     }
@@ -4534,6 +4902,86 @@ static void updateBoundControls(BasFormT *form, BasControlT *dataCtrl) {
 }
 
 
+bool wgtApplyPropFromString(WidgetT *w, const WgtPropDescT *p, const char *val) {
+    if (!w || !p || !p->setFn || !val) {
+        return false;
+    }
+
+    if (p->type == WGT_IFACE_ENUM && p->enumNames) {
+        for (int32_t en = 0; p->enumNames[en]; en++) {
+            if (strcasecmp(p->enumNames[en], val) == 0) {
+                ((void (*)(WidgetT *, int32_t))p->setFn)(w, en);
+                return true;
+            }
+        }
+        return false;
+    }
+
+    if (p->type == WGT_IFACE_INT) {
+        ((void (*)(WidgetT *, int32_t))p->setFn)(w, atoi(val));
+        return true;
+    }
+
+    if (p->type == WGT_IFACE_BOOL) {
+        ((void (*)(WidgetT *, bool))p->setFn)(w, frmParseBool(val));
+        return true;
+    }
+
+    if (p->type == WGT_IFACE_STRING) {
+        ((void (*)(WidgetT *, const char *))p->setFn)(w, val);
+        return true;
+    }
+
+    return false;
+}
+
+
+bool wgtPropValueToString(const WidgetT *w, const WgtPropDescT *p, char *out, int32_t outSize) {
+    if (!w || !p || !p->getFn || !out || outSize <= 0) {
+        return false;
+    }
+
+    if (p->type == WGT_IFACE_ENUM && p->enumNames) {
+        int32_t     v    = ((int32_t (*)(const WidgetT *))p->getFn)(w);
+        const char *name = NULL;
+
+        for (int32_t en = 0; p->enumNames[en]; en++) {
+            if (en == v) {
+                name = p->enumNames[en];
+                break;
+            }
+        }
+
+        if (!name) {
+            return false;
+        }
+
+        snprintf(out, outSize, "%s", name);
+        return true;
+    }
+
+    if (p->type == WGT_IFACE_INT) {
+        int32_t v = ((int32_t (*)(const WidgetT *))p->getFn)(w);
+        snprintf(out, outSize, "%d", (int)v);
+        return true;
+    }
+
+    if (p->type == WGT_IFACE_BOOL) {
+        bool v = ((bool (*)(const WidgetT *))p->getFn)(w);
+        snprintf(out, outSize, "%s", v ? "True" : "False");
+        return true;
+    }
+
+    if (p->type == WGT_IFACE_STRING) {
+        const char *s = ((const char *(*)(const WidgetT *))p->getFn)(w);
+        snprintf(out, outSize, "%s", s ? s : "");
+        return true;
+    }
+
+    return false;
+}
+
+
 static BasValueT zeroValue(void) {
     BasValueT v;
     memset(&v, 0, sizeof(v));
diff --git a/src/apps/kpunch/dvxbasic/formrt/formrt.h b/src/apps/kpunch/dvxbasic/formrt/formrt.h
index 8b84a2c..5eef1c6 100644
--- a/src/apps/kpunch/dvxbasic/formrt/formrt.h
+++ b/src/apps/kpunch/dvxbasic/formrt/formrt.h
@@ -87,6 +87,15 @@ typedef struct BasControlT {
     int32_t           menuId;                        // WM menu item ID (>0 for menu items, 0 for controls)
     BasEventOverrideT eventOverrides[BAS_MAX_EVENT_OVERRIDES];
     int32_t           eventOverrideCount;
+    // Re-entrancy guard: set while an event handler for this control
+    // is running.  runSubLoop pumps events during long-running handlers
+    // (painting, draw loops), and without this guard a lingering
+    // mouse-up or repaint cycle can re-fire the same Click on top of
+    // itself -- an unbounded recursion.  firingEventName records which
+    // event is in flight so different-event delivery (e.g. LostFocus
+    // while Click is still running) is allowed through.
+    bool              eventFiring;
+    char              firingEventName[BAS_MAX_CTRL_NAME];
 } BasControlT;
 
 // ============================================================
@@ -153,6 +162,10 @@ typedef struct {
     int32_t           frmCacheCount;
     BasCfmCacheT     *cfmCache;                // stb_ds array of compiled form binaries
     int32_t           cfmCacheCount;
+    // Set true when a runtime error has halted the program; the event
+    // loop exits at the next pump so the app doesn't stumble forward
+    // with a halted VM.
+    bool              terminated;
 } BasFormRtT;
 
 // ============================================================
@@ -209,7 +222,28 @@ void basFormRtRegisterCfm(BasFormRtT *rt, const char *formName, const uint8_t *d
 
 // ---- Widget creation ----
 
-WidgetT  *createWidget(const char *wgtTypeName, WidgetT *parent);
+// Create a widget by resolved (DVX) type name. Returns NULL if the type
+// is unknown.  createWidgetByIface dispatches on the interface
+// descriptor's createSig; pass allowData=true for an empty pixel-data
+// widget (runtime path), or false to refuse (design-time path).
+WidgetT *createWidget(const char *wgtTypeName, WidgetT *parent);
+WidgetT *createWidgetByIface(const WgtIfaceT *iface, const void *api, WidgetT *parent, bool allowData);
+
+// Resolve a VB-style name ("CommandButton") to a DVX widget name
+// ("button"). Falls back to the same name if already canonical. Returns
+// NULL if the type is unknown.
+const char *resolveTypeName(const char *typeName);
+
+// Apply a "Key = Value" string pair to a widget property via the
+// interface descriptor. Handles ENUM/INT/BOOL/STRING dispatch.  Returns
+// true if the property was set, false if unsupported type / no setter.
+bool wgtApplyPropFromString(WidgetT *w, const WgtPropDescT *p, const char *val);
+
+// Read a widget property via the interface getter and format it into
+// out as a .frm-style value ("True", "False", enum name, or decimal
+// string).  Returns true if the property was read; false if the getter
+// is NULL, the type is not serializable, or the enum value is unknown.
+bool wgtPropValueToString(const WidgetT *w, const WgtPropDescT *p, char *out, int32_t outSize);
 
 // ---- Form window creation ----
 
diff --git a/src/apps/kpunch/dvxbasic/formrt/frmParser.c b/src/apps/kpunch/dvxbasic/formrt/frmParser.c
new file mode 100644
index 0000000..ee4e664
--- /dev/null
+++ b/src/apps/kpunch/dvxbasic/formrt/frmParser.c
@@ -0,0 +1,308 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// frmParser.c -- callback-based .frm text parser
+//
+// See frmParser.h for the public API.
+
+#include "frmParser.h"
+#include "dvxPlat.h"
+
+#include 
+#include 
+#include 
+
+#define FRM_MAX_LINE_LEN      512
+#define FRM_MAX_TOKEN_LEN     64
+#define FRM_MAX_NESTING       16
+
+
+// Prototypes (alphabetical)
+bool frmParse(const char *source, int32_t sourceLen, const FrmParserCbsT *cb);
+bool frmParseBool(const char *val);
+void frmParseKeyValue(const char *line, char *key, int32_t keyMax, char *value, int32_t valueMax);
+void frmStripQuotes(char *val);
+static bool readToken(const char **p, const char *end, char *buf, int32_t bufMax);
+
+
+typedef enum {
+    BLK_FORM,
+    BLK_MENU,
+    BLK_CTRL
+} BlkTypeE;
+
+
+bool frmParse(const char *source, int32_t sourceLen, const FrmParserCbsT *cb) {
+    if (!source || sourceLen <= 0 || !cb) {
+        return false;
+    }
+
+    BlkTypeE blockStack[FRM_MAX_NESTING];
+    int32_t  blockDepth    = 0;
+    int32_t  menuNestDepth = 0;
+    bool     inForm        = false;
+
+    const char *pos = source;
+    const char *end = source + sourceLen;
+
+    while (pos < end) {
+        const char *lineStart = pos;
+
+        while (pos < end && *pos != '\n' && *pos != '\r') {
+            pos++;
+        }
+
+        int32_t lineLen = (int32_t)(pos - lineStart);
+
+        if (pos < end && *pos == '\r') {
+            pos++;
+        }
+
+        if (pos < end && *pos == '\n') {
+            pos++;
+        }
+
+        char line[FRM_MAX_LINE_LEN];
+
+        if (lineLen >= FRM_MAX_LINE_LEN) {
+            lineLen = FRM_MAX_LINE_LEN - 1;
+        }
+
+        memcpy(line, lineStart, lineLen);
+        line[lineLen] = '\0';
+
+        const char *trimmed = dvxSkipWs(line);
+
+        if (*trimmed == '\0' || *trimmed == '\'') {
+            continue;
+        }
+
+        // "VERSION DVX x.xx" (native) or "VERSION x.xx" (VB import)
+        if (strncasecmp(trimmed, "VERSION ", 8) == 0) {
+            const char *ver = trimmed + 8;
+
+            if (strncasecmp(ver, "DVX ", 4) != 0) {
+                double vbVer = atof(ver);
+
+                if (vbVer > 2.0) {
+                    return false; // VB4+ form, not compatible
+                }
+            }
+
+            continue;
+        }
+
+        // "Begin TypeName CtrlName"
+        if (strncasecmp(trimmed, "Begin ", 6) == 0) {
+            const char *rest = trimmed + 6;
+            char        typeName[FRM_MAX_TOKEN_LEN];
+            char        ctrlName[FRM_MAX_TOKEN_LEN];
+
+            readToken(&rest, NULL, typeName, FRM_MAX_TOKEN_LEN);
+            rest = dvxSkipWs(rest);
+            readToken(&rest, NULL, ctrlName, FRM_MAX_TOKEN_LEN);
+
+            if (typeName[0] == '\0') {
+                continue;
+            }
+
+            if (strcasecmp(typeName, "Form") == 0) {
+                if (cb->onFormBegin && !cb->onFormBegin(cb->userData, ctrlName)) {
+                    return false;
+                }
+
+                inForm = true;
+
+                if (blockDepth < FRM_MAX_NESTING) {
+                    blockStack[blockDepth++] = BLK_FORM;
+                }
+            } else if (strcasecmp(typeName, "Menu") == 0 && inForm) {
+                if (cb->onMenuBegin) {
+                    cb->onMenuBegin(cb->userData, ctrlName, menuNestDepth);
+                }
+
+                menuNestDepth++;
+
+                if (blockDepth < FRM_MAX_NESTING) {
+                    blockStack[blockDepth++] = BLK_MENU;
+                }
+            } else if (inForm) {
+                if (cb->onCtrlBegin) {
+                    cb->onCtrlBegin(cb->userData, typeName, ctrlName);
+                }
+
+                if (blockDepth < FRM_MAX_NESTING) {
+                    blockStack[blockDepth++] = BLK_CTRL;
+                }
+            }
+
+            continue;
+        }
+
+        // "End"
+        if (strcasecmp(trimmed, "End") == 0) {
+            if (blockDepth == 0) {
+                continue;
+            }
+
+            blockDepth--;
+            BlkTypeE blk = blockStack[blockDepth];
+
+            if (blk == BLK_MENU) {
+                if (cb->onMenuEnd) {
+                    cb->onMenuEnd(cb->userData);
+                }
+
+                menuNestDepth--;
+
+                if (menuNestDepth < 0) {
+                    menuNestDepth = 0;
+                }
+            } else if (blk == BLK_CTRL) {
+                if (cb->onCtrlEnd) {
+                    cb->onCtrlEnd(cb->userData);
+                }
+            } else {
+                // Outer Form End -- deliver any trailing text and stop.
+                inForm = false;
+
+                if (cb->onFormEnd) {
+                    cb->onFormEnd(cb->userData, pos, (int32_t)(end - pos));
+                }
+
+                return true;
+            }
+
+            continue;
+        }
+
+        // Property assignment: Key = Value
+        char key[FRM_MAX_TOKEN_LEN];
+        char value[FRM_MAX_LINE_LEN];
+        frmParseKeyValue(trimmed, key, FRM_MAX_TOKEN_LEN, value, FRM_MAX_LINE_LEN);
+
+        if (key[0] == '\0' || !inForm) {
+            continue;
+        }
+
+        BlkTypeE curBlock = (blockDepth > 0) ? blockStack[blockDepth - 1] : BLK_FORM;
+
+        if (curBlock == BLK_MENU) {
+            if (cb->onMenuProp) {
+                cb->onMenuProp(cb->userData, key, value);
+            }
+        } else if (curBlock == BLK_CTRL) {
+            if (cb->onCtrlProp) {
+                cb->onCtrlProp(cb->userData, key, value);
+            }
+        } else {
+            if (cb->onFormProp) {
+                cb->onFormProp(cb->userData, key, value);
+            }
+        }
+    }
+
+    return true;
+}
+
+
+bool frmParseBool(const char *val) {
+    if (!val) {
+        return false;
+    }
+
+    return (strcasecmp(val, "True") == 0 || strcasecmp(val, "-1") == 0);
+}
+
+
+void frmParseKeyValue(const char *line, char *key, int32_t keyMax, char *value, int32_t valueMax) {
+    key[0]   = '\0';
+    value[0] = '\0';
+
+    line = dvxSkipWs(line);
+
+    int32_t ki = 0;
+
+    while (*line && *line != '=' && *line != ' ' && *line != '\t' && ki < keyMax - 1) {
+        key[ki++] = *line++;
+    }
+
+    key[ki] = '\0';
+
+    line = dvxSkipWs(line);
+
+    if (*line == '=') {
+        line++;
+    }
+
+    line = dvxSkipWs(line);
+
+    int32_t vi = 0;
+
+    while (*line && *line != '\r' && *line != '\n' && vi < valueMax - 1) {
+        value[vi++] = *line++;
+    }
+
+    value[vi] = '\0';
+
+    while (vi > 0 && (value[vi - 1] == ' ' || value[vi - 1] == '\t')) {
+        value[--vi] = '\0';
+    }
+}
+
+
+void frmStripQuotes(char *val) {
+    if (!val || val[0] != '"') {
+        return;
+    }
+
+    int32_t len = (int32_t)strlen(val);
+
+    if (len >= 2 && val[len - 1] == '"') {
+        memmove(val, val + 1, len - 2);
+        val[len - 2] = '\0';
+    } else if (len >= 1) {
+        // Unterminated leading quote -- strip just the leading "
+        memmove(val, val + 1, len - 1);
+        val[len - 1] = '\0';
+    }
+}
+
+
+// Read a whitespace-delimited token starting at *p.  Advances *p past
+// the token.  If end is non-NULL, reading stops before end.
+static bool readToken(const char **p, const char *end, char *buf, int32_t bufMax) {
+    const char *cur = *p;
+    int32_t     len = 0;
+
+    while (*cur && *cur != ' ' && *cur != '\t' && *cur != '\r' && *cur != '\n' && len < bufMax - 1) {
+        if (end && cur >= end) {
+            break;
+        }
+
+        buf[len++] = *cur++;
+    }
+
+    buf[len] = '\0';
+    *p       = cur;
+    return (len > 0);
+}
diff --git a/src/apps/kpunch/dvxbasic/formrt/frmParser.h b/src/apps/kpunch/dvxbasic/formrt/frmParser.h
new file mode 100644
index 0000000..b7d00b2
--- /dev/null
+++ b/src/apps/kpunch/dvxbasic/formrt/frmParser.h
@@ -0,0 +1,88 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// frmParser.h -- callback-based .frm text parser
+//
+// Shared by the form runtime (formrt.c) and the visual designer
+// (ideDesigner.c). The parser walks the .frm text, recognises
+// "Begin Form", "Begin Menu", "Begin ", "End", and
+// "Key = Value" lines, and calls back into the caller for each
+// semantic event. The caller owns all data structures -- the
+// parser keeps only internal block-nesting state.
+
+#ifndef DVXBASIC_FRMPARSER_H
+#define DVXBASIC_FRMPARSER_H
+
+#include 
+#include 
+
+// Callbacks supplied by the consumer. Any field may be NULL.
+typedef struct FrmParserCbsT {
+    void *userData;
+
+    // "Begin Form ".  Return false to abort parsing.
+    bool (*onFormBegin)(void *userData, const char *name);
+
+    // Form-level "Key = Value" property (not inside any control or menu).
+    void (*onFormProp)(void *userData, const char *key, const char *value);
+
+    // "Begin Menu " at the given nesting level (0 = top-level).
+    void (*onMenuBegin)(void *userData, const char *name, int32_t level);
+
+    // "End" for a menu item.
+    void (*onMenuEnd)(void *userData);
+
+    // Menu-item "Key = Value" property.
+    void (*onMenuProp)(void *userData, const char *key, const char *value);
+
+    // "Begin  " for a non-Form, non-Menu control.
+    void (*onCtrlBegin)(void *userData, const char *typeName, const char *name);
+
+    // "End" for a control block.
+    void (*onCtrlEnd)(void *userData);
+
+    // Control-level "Key = Value" property.
+    void (*onCtrlProp)(void *userData, const char *key, const char *value);
+
+    // Outer Form's "End" was reached.  trailingSrc points into the
+    // input buffer just past the End, with trailingLen bytes remaining.
+    // The designer uses this to capture the BASIC code section.
+    void (*onFormEnd)(void *userData, const char *trailingSrc, int32_t trailingLen);
+} FrmParserCbsT;
+
+// Parse a .frm text.  Returns true on success, false if the VERSION
+// line rejects the input or onFormBegin returns false.
+bool frmParse(const char *source, int32_t sourceLen, const FrmParserCbsT *cb);
+
+// Parse a trimmed "Key = Value" line into key and value buffers.
+// value has trailing whitespace stripped but quotes are preserved.
+void frmParseKeyValue(const char *line, char *key, int32_t keyMax, char *value, int32_t valueMax);
+
+// Strip surrounding double quotes from val, in place.  No-op if the
+// string is not quoted.
+void frmStripQuotes(char *val);
+
+// Classify a value string as a BASIC boolean.  True / -1 -> true;
+// anything else -> false.
+bool frmParseBool(const char *val);
+
+#endif // DVXBASIC_FRMPARSER_H
diff --git a/src/apps/kpunch/dvxbasic/ide/ideDesigner.c b/src/apps/kpunch/dvxbasic/ide/ideDesigner.c
index 103f8c0..13787d1 100644
--- a/src/apps/kpunch/dvxbasic/ide/ideDesigner.c
+++ b/src/apps/kpunch/dvxbasic/ide/ideDesigner.c
@@ -28,6 +28,7 @@
 
 #include "ideDesigner.h"
 #include "../formrt/formrt.h"
+#include "../formrt/frmParser.h"
 #include "dvxDraw.h"
 #include "dvxVideo.h"
 #include "dvxWm.h"
@@ -61,16 +62,39 @@ static const char *FORM_DEFAULT_EVENT = "Load";
 // ============================================================
 
 // dsgnCreateDesignWidget is declared in ideDesigner.h (non-static)
+static void         dsgnLoad_onCtrlBegin(void *userData, const char *typeName, const char *name);
+static void         dsgnLoad_onCtrlEnd(void *userData);
+static void         dsgnLoad_onCtrlProp(void *userData, const char *key, const char *value);
+static bool         dsgnLoad_onFormBegin(void *userData, const char *name);
+static void         dsgnLoad_onFormEnd(void *userData, const char *trailingSrc, int32_t trailingLen);
+static void         dsgnLoad_onFormProp(void *userData, const char *key, const char *value);
+static void         dsgnLoad_onMenuBegin(void *userData, const char *name, int32_t level);
+static void         dsgnLoad_onMenuEnd(void *userData);
+static void         dsgnLoad_onMenuProp(void *userData, const char *key, const char *value);
 static const char  *getPropValue(const DsgnControlT *ctrl, const char *name);
 static int32_t      hitTestControl(const DsgnStateT *ds, int32_t x, int32_t y);
 static DsgnHandleE  hitTestHandles(const DsgnControlT *ctrl, int32_t x, int32_t y);
 static void         rebuildWidgets(DsgnStateT *ds);
-static const char  *resolveTypeName(const char *typeName);
 static int32_t      saveControls(const DsgnFormT *form, char *buf, int32_t bufSize, int32_t pos, const char *parentName, int32_t indent);
 static void         setPropValue(DsgnControlT *ctrl, const char *name, const char *value);
 static void         syncWidgetGeom(DsgnControlT *ctrl);
 
 
+// ============================================================
+// .frm parsing context (used by dsgnLoad_* callbacks)
+// ============================================================
+
+typedef struct {
+    DsgnFormT     *form;
+    DsgnControlT  *current;
+    int32_t        curMenuItemIdx;
+    char           parentStack[8][DSGN_MAX_NAME];
+    int32_t        nestDepth;
+    bool           containerStack[BAS_MAX_FRM_NESTING];
+    int32_t        containerDepth;
+} DsgnFrmLoadCtxT;
+
+
 void dsgnAutoName(const DsgnStateT *ds, const char *typeName, char *buf, int32_t bufSize) {
     // Look up the name prefix from the widget interface descriptor.
     // Falls back to the type name itself if no prefix is registered.
@@ -156,8 +180,10 @@ WidgetT *dsgnCreateContentBox(WidgetT *root, const char *layout) {
 }
 
 
-// Create a real DVX widget for design-time display. Mirrors the
-// logic in formrt.c createWidget().
+// Create a real DVX widget for design-time display.  Uses the shared
+// createWidgetByIface switch from formrt; design-time refuses
+// WGT_CREATE_PARENT_DATA widgets (Image/ImageButton) since we have no
+// pixel data.
 WidgetT *dsgnCreateDesignWidget(const char *vbTypeName, WidgetT *parent) {
     const char *wgtName = resolveTypeName(vbTypeName);
 
@@ -171,50 +197,7 @@ WidgetT *dsgnCreateDesignWidget(const char *vbTypeName, WidgetT *parent) {
         return NULL;
     }
 
-    const WgtIfaceT *iface = wgtGetIface(wgtName);
-    uint8_t sig = iface ? iface->createSig : WGT_CREATE_PARENT;
-
-    typedef WidgetT *(*CreateParentFnT)(WidgetT *);
-    typedef WidgetT *(*CreateParentTextFnT)(WidgetT *, const char *);
-    typedef WidgetT *(*CreateParentIntFnT)(WidgetT *, int32_t);
-    typedef WidgetT *(*CreateParentIntIntFnT)(WidgetT *, int32_t, int32_t);
-    typedef WidgetT *(*CreateParentIntIntIntFnT)(WidgetT *, int32_t, int32_t, int32_t);
-    typedef WidgetT *(*CreateParentIntBoolFnT)(WidgetT *, int32_t, bool);
-    typedef WidgetT *(*CreateParentBoolFnT)(WidgetT *, bool);
-
-    switch (sig) {
-        case WGT_CREATE_PARENT_TEXT: {
-            CreateParentTextFnT fn = *(CreateParentTextFnT *)api;
-            return fn(parent, "");
-        }
-        case WGT_CREATE_PARENT_INT: {
-            CreateParentIntFnT fn = *(CreateParentIntFnT *)api;
-            return fn(parent, iface->createArgs[0]);
-        }
-        case WGT_CREATE_PARENT_INT_INT: {
-            CreateParentIntIntFnT fn = *(CreateParentIntIntFnT *)api;
-            return fn(parent, iface->createArgs[0], iface->createArgs[1]);
-        }
-        case WGT_CREATE_PARENT_INT_INT_INT: {
-            CreateParentIntIntIntFnT fn = *(CreateParentIntIntIntFnT *)api;
-            return fn(parent, iface->createArgs[0], iface->createArgs[1], iface->createArgs[2]);
-        }
-        case WGT_CREATE_PARENT_INT_BOOL: {
-            CreateParentIntBoolFnT fn = *(CreateParentIntBoolFnT *)api;
-            return fn(parent, iface->createArgs[0], (bool)iface->createArgs[1]);
-        }
-        case WGT_CREATE_PARENT_BOOL: {
-            CreateParentBoolFnT fn = *(CreateParentBoolFnT *)api;
-            return fn(parent, (bool)iface->createArgs[0]);
-        }
-        case WGT_CREATE_PARENT_DATA:
-            // Image/ImageButton -- cannot auto-create without pixel data
-            return NULL;
-        default: {
-            CreateParentFnT fn = *(CreateParentFnT *)api;
-            return fn(parent);
-        }
-    }
+    return createWidgetByIface(wgtGetIface(wgtName), api, parent, false);
 }
 
 
@@ -320,23 +303,8 @@ void dsgnCreateWidgets(DsgnStateT *ds, WidgetT *contentBox) {
 
                 const char *val = getPropValue(ctrl, p->name);
 
-                if (!val) {
-                    continue;
-                }
-
-                if (p->type == WGT_IFACE_ENUM && p->enumNames) {
-                    for (int32_t en = 0; p->enumNames[en]; en++) {
-                        if (strcasecmp(p->enumNames[en], val) == 0) {
-                            ((void (*)(WidgetT *, int32_t))p->setFn)(w, en);
-                            break;
-                        }
-                    }
-                } else if (p->type == WGT_IFACE_INT) {
-                    ((void (*)(WidgetT *, int32_t))p->setFn)(w, atoi(val));
-                } else if (p->type == WGT_IFACE_BOOL) {
-                    ((void (*)(WidgetT *, bool))p->setFn)(w, strcasecmp(val, "True") == 0);
-                } else if (p->type == WGT_IFACE_STRING) {
-                    ((void (*)(WidgetT *, const char *))p->setFn)(w, val);
+                if (val) {
+                    wgtApplyPropFromString(w, p, val);
                 }
             }
         }
@@ -427,259 +395,27 @@ bool dsgnLoadFrm(DsgnStateT *ds, const char *source, int32_t sourceLen) {
     snprintf(form->name, DSGN_MAX_NAME, "Form1");
     snprintf(form->caption, DSGN_MAX_TEXT, "Form1");
 
-    DsgnControlT  *curCtrl     = NULL;
-    DsgnMenuItemT *curMenuItem = NULL;
-    bool inForm = false;
-    bool inMenu = false;
-    int32_t menuNestDepth = 0;
-    int32_t blockDepth    = 0; // Begin/End nesting depth (0 = form level)
-    bool    blockIsContainer[BAS_MAX_FRM_NESTING]; // whether each block is a container
+    DsgnFrmLoadCtxT ctx;
+    memset(&ctx, 0, sizeof(ctx));
+    ctx.form           = form;
+    ctx.curMenuItemIdx = -1;
 
-    // Parent name stack for nesting (index 0 = form level)
-    char parentStack[8][DSGN_MAX_NAME];
-    int32_t nestDepth = 0;
-    parentStack[0][0] = '\0';
+    FrmParserCbsT cbs;
+    memset(&cbs, 0, sizeof(cbs));
+    cbs.userData    = &ctx;
+    cbs.onFormBegin = dsgnLoad_onFormBegin;
+    cbs.onFormProp  = dsgnLoad_onFormProp;
+    cbs.onFormEnd   = dsgnLoad_onFormEnd;
+    cbs.onMenuBegin = dsgnLoad_onMenuBegin;
+    cbs.onMenuEnd   = dsgnLoad_onMenuEnd;
+    cbs.onMenuProp  = dsgnLoad_onMenuProp;
+    cbs.onCtrlBegin = dsgnLoad_onCtrlBegin;
+    cbs.onCtrlEnd   = dsgnLoad_onCtrlEnd;
+    cbs.onCtrlProp  = dsgnLoad_onCtrlProp;
 
-    const char *pos = source;
-    const char *end = source + sourceLen;
-
-    while (pos < end) {
-        const char *lineStart = pos;
-
-        while (pos < end && *pos != '\n' && *pos != '\r') {
-            pos++;
-        }
-
-        int32_t lineLen = (int32_t)(pos - lineStart);
-
-        if (pos < end && *pos == '\r') { pos++; }
-        if (pos < end && *pos == '\n') { pos++; }
-
-        char line[BAS_MAX_FRM_LINE_LEN];
-
-        if (lineLen >= BAS_MAX_FRM_LINE_LEN) {
-            lineLen = BAS_MAX_FRM_LINE_LEN - 1;
-        }
-
-        memcpy(line, lineStart, lineLen);
-        line[lineLen] = '\0';
-
-        const char *trimmed = dvxSkipWs(line);
-
-        if (*trimmed == '\0' || *trimmed == '\'') {
-            continue;
-        }
-
-        if (strncasecmp(trimmed, "VERSION ", 8) == 0) {
-            // Accept "VERSION DVX x.xx" (native) and "VERSION x.xx" (VB import).
-            // Reject VB forms with version > 1.xx (VB4+/VB6 use features we
-            // don't support like OLE controls and binary properties).
-            const char *ver = trimmed + 8;
-
-            if (strncasecmp(ver, "DVX ", 4) == 0) {
-                // Native DVX BASIC form -- always accepted
-            } else {
-                // VB form -- check version number
-                double vbVer = atof(ver);
-
-                if (vbVer > 2.0) {
-                    return false; // VB4+ form, not compatible
-                }
-            }
-
-            continue;
-        }
-
-        // Begin TypeName CtrlName
-        if (strncasecmp(trimmed, "Begin ", 6) == 0) {
-            const char *rest = trimmed + 6;
-            char typeName[DSGN_MAX_NAME];
-            char ctrlName[DSGN_MAX_NAME];
-            int32_t ti = 0;
-
-            while (*rest && *rest != ' ' && *rest != '\t' && ti < DSGN_MAX_NAME - 1) {
-                typeName[ti++] = *rest++;
-            }
-
-            typeName[ti] = '\0';
-
-            rest = dvxSkipWs(rest);
-
-            int32_t ci = 0;
-
-            while (*rest && *rest != ' ' && *rest != '\t' && *rest != '\r' && *rest != '\n' && ci < DSGN_MAX_NAME - 1) {
-                ctrlName[ci++] = *rest++;
-            }
-
-            ctrlName[ci] = '\0';
-
-            if (strcasecmp(typeName, "Form") == 0) {
-                snprintf(form->name, DSGN_MAX_NAME, "%s", ctrlName);
-                snprintf(form->caption, DSGN_MAX_TEXT, "%s", ctrlName);
-                inForm    = true;
-                nestDepth = 0;
-                curCtrl   = NULL;
-            } else if (strcasecmp(typeName, "Menu") == 0 && inForm) {
-                DsgnMenuItemT mi;
-                memset(&mi, 0, sizeof(mi));
-                snprintf(mi.name, DSGN_MAX_NAME, "%s", ctrlName);
-                mi.level   = menuNestDepth;
-                mi.enabled = true;
-                arrput(form->menuItems, mi);
-                curMenuItem = &form->menuItems[arrlen(form->menuItems) - 1];
-                curCtrl     = NULL; // not a control
-                menuNestDepth++;
-                inMenu = true;
-                if (blockDepth < BAS_MAX_FRM_NESTING) { blockIsContainer[blockDepth] = false; }
-                blockDepth++;
-            } else if (inForm) {
-                DsgnControlT *cp = (DsgnControlT *)calloc(1, sizeof(DsgnControlT));
-                cp->index = -1;
-                snprintf(cp->name, DSGN_MAX_NAME, "%s", ctrlName);
-                snprintf(cp->typeName, DSGN_MAX_NAME, "%s", typeName);
-
-                // Set parent from current nesting
-                if (nestDepth > 0) {
-                    snprintf(cp->parentName, DSGN_MAX_NAME, "%s", parentStack[nestDepth - 1]);
-                }
-
-                cp->width    = DEFAULT_CTRL_W;
-                cp->height   = DEFAULT_CTRL_H;
-                arrput(form->controls, cp);
-                curCtrl = form->controls[arrlen(form->controls) - 1];
-                bool isCtrl = dsgnIsContainer(typeName);
-                if (blockDepth < BAS_MAX_FRM_NESTING) { blockIsContainer[blockDepth] = isCtrl; }
-                blockDepth++;
-
-                // If this is a container, push onto parent stack
-                if (isCtrl && nestDepth < 7) {
-                    snprintf(parentStack[nestDepth], DSGN_MAX_NAME, "%s", ctrlName);
-                    nestDepth++;
-                }
-            }
-
-            continue;
-        }
-
-        if (strcasecmp(trimmed, "End") == 0) {
-            if (blockDepth > 0) {
-                blockDepth--;
-
-                if (inMenu) {
-                    menuNestDepth--;
-                    curMenuItem = NULL;
-
-                    if (menuNestDepth <= 0) {
-                        menuNestDepth = 0;
-                        inMenu = false;
-                    }
-                } else {
-                    // If we're closing a container, pop the parent stack
-                    if (blockDepth < BAS_MAX_FRM_NESTING && blockIsContainer[blockDepth] && nestDepth > 0) {
-                        nestDepth--;
-                    }
-
-                    curCtrl = NULL;
-                }
-            } else {
-                // blockDepth == 0: this is the form's closing End
-                inForm = false;
-
-                // Everything after the form's closing End is code
-                if (pos < end) {
-                    // Skip leading blank lines
-                    const char *codeStart = pos;
-
-                    while (codeStart < end && (*codeStart == '\r' || *codeStart == '\n' || *codeStart == ' ' || *codeStart == '\t')) {
-                        codeStart++;
-                    }
-
-                    if (codeStart < end) {
-                        int32_t codeLen = (int32_t)(end - codeStart);
-                        form->code = (char *)malloc(codeLen + 1);
-
-                        if (form->code) {
-                            memcpy(form->code, codeStart, codeLen);
-                            form->code[codeLen] = '\0';
-                        }
-                    }
-                }
-
-                break; // done parsing
-            }
-
-            continue;
-        }
-
-        // Property = Value
-        const char *eq = strchr(trimmed, '=');
-
-        if (eq && inForm) {
-            char key[DSGN_MAX_NAME];
-            const char *kend = eq - 1;
-
-            while (kend > trimmed && (*kend == ' ' || *kend == '\t')) { kend--; }
-
-            int32_t klen = (int32_t)(kend - trimmed + 1);
-
-            if (klen >= DSGN_MAX_NAME) { klen = DSGN_MAX_NAME - 1; }
-
-            memcpy(key, trimmed, klen);
-            key[klen] = '\0';
-
-            const char *vstart = dvxSkipWs(eq + 1);
-
-            char val[DSGN_MAX_TEXT];
-            int32_t vi = 0;
-
-            if (*vstart == '"') {
-                vstart++;
-
-                while (*vstart && *vstart != '"' && vi < DSGN_MAX_TEXT - 1) {
-                    val[vi++] = *vstart++;
-                }
-            } else {
-                while (*vstart && *vstart != '\r' && *vstart != '\n' && vi < DSGN_MAX_TEXT - 1) {
-                    val[vi++] = *vstart++;
-                }
-
-                while (vi > 0 && (val[vi - 1] == ' ' || val[vi - 1] == '\t')) { vi--; }
-            }
-
-            val[vi] = '\0';
-
-            if (curMenuItem) {
-                if (strcasecmp(key, "Caption") == 0)         { snprintf(curMenuItem->caption, DSGN_MAX_TEXT, "%s", val); }
-                else if (strcasecmp(key, "Checked") == 0)    { curMenuItem->checked = (strcasecmp(val, "True") == 0 || strcasecmp(val, "-1") == 0); }
-                else if (strcasecmp(key, "RadioCheck") == 0) { curMenuItem->radioCheck = (strcasecmp(val, "True") == 0 || strcasecmp(val, "-1") == 0); }
-                else if (strcasecmp(key, "Enabled") == 0)    { curMenuItem->enabled = (strcasecmp(val, "True") == 0 || strcasecmp(val, "-1") == 0); }
-            } else if (curCtrl) {
-                if (strcasecmp(key, "Left") == 0)              { curCtrl->left      = atoi(val); }
-                else if (strcasecmp(key, "Top") == 0)          { curCtrl->top       = atoi(val); }
-                else if (strcasecmp(key, "MinWidth") == 0 ||
-                         strcasecmp(key, "Width") == 0)        { curCtrl->width     = atoi(val); }
-                else if (strcasecmp(key, "MinHeight") == 0 ||
-                         strcasecmp(key, "Height") == 0)       { curCtrl->height    = atoi(val); }
-                else if (strcasecmp(key, "MaxWidth") == 0)  { curCtrl->maxWidth  = atoi(val); }
-                else if (strcasecmp(key, "MaxHeight") == 0) { curCtrl->maxHeight = atoi(val); }
-                else if (strcasecmp(key, "Weight") == 0)    { curCtrl->weight    = atoi(val); }
-                else if (strcasecmp(key, "Index") == 0)      { curCtrl->index     = atoi(val); }
-                else if (strcasecmp(key, "HelpTopic") == 0) { snprintf(curCtrl->helpTopic, DSGN_MAX_NAME, "%s", val); }
-                else if (strcasecmp(key, "TabIndex") == 0)   { /* ignored -- DVX has no tab order */ }
-                else { setPropValue(curCtrl, key, val); }
-            } else {
-                if (strcasecmp(key, "Caption") == 0)       { snprintf(form->caption, DSGN_MAX_TEXT, "%s", val); }
-                else if (strcasecmp(key, "Layout") == 0)    { strncpy(form->layout, val, DSGN_MAX_NAME - 1); form->layout[DSGN_MAX_NAME - 1] = '\0'; }
-                else if (strcasecmp(key, "AutoSize") == 0)  { form->autoSize  = (strcasecmp(val, "True") == 0); }
-                else if (strcasecmp(key, "Resizable") == 0) { form->resizable = (strcasecmp(val, "True") == 0); }
-                else if (strcasecmp(key, "Centered") == 0)  { form->centered  = (strcasecmp(val, "True") == 0); }
-                else if (strcasecmp(key, "Left") == 0)      { form->left   = atoi(val); }
-                else if (strcasecmp(key, "Top") == 0)       { form->top    = atoi(val); }
-                else if (strcasecmp(key, "Width") == 0)     { form->width  = atoi(val); form->autoSize = false; }
-                else if (strcasecmp(key, "Height") == 0)    { form->height = atoi(val); form->autoSize = false; }
-                else if (strcasecmp(key, "HelpTopic") == 0) { snprintf(form->helpTopic, DSGN_MAX_NAME, "%s", val); }
-            }
-        }
+    if (!frmParse(source, sourceLen, &cbs)) {
+        free(form);
+        return false;
     }
 
     ds->form        = form;
@@ -688,6 +424,239 @@ bool dsgnLoadFrm(DsgnStateT *ds, const char *source, int32_t sourceLen) {
 }
 
 
+static void dsgnLoad_onCtrlBegin(void *userData, const char *typeName, const char *name) {
+    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;
+
+    if (!ctx->form) {
+        return;
+    }
+
+    DsgnControlT *cp = (DsgnControlT *)calloc(1, sizeof(DsgnControlT));
+
+    if (!cp) {
+        if (ctx->containerDepth < BAS_MAX_FRM_NESTING) {
+            ctx->containerStack[ctx->containerDepth++] = false;
+        }
+        return;
+    }
+
+    cp->index = -1;
+    snprintf(cp->name, DSGN_MAX_NAME, "%s", name);
+    snprintf(cp->typeName, DSGN_MAX_NAME, "%s", typeName);
+
+    if (ctx->nestDepth > 0) {
+        snprintf(cp->parentName, DSGN_MAX_NAME, "%s", ctx->parentStack[ctx->nestDepth - 1]);
+    }
+
+    cp->width  = DEFAULT_CTRL_W;
+    cp->height = DEFAULT_CTRL_H;
+    arrput(ctx->form->controls, cp);
+    ctx->current = ctx->form->controls[arrlen(ctx->form->controls) - 1];
+
+    bool isCtrl = dsgnIsContainer(typeName);
+
+    if (ctx->containerDepth < BAS_MAX_FRM_NESTING) {
+        ctx->containerStack[ctx->containerDepth++] = isCtrl;
+    }
+
+    if (isCtrl && ctx->nestDepth < 7) {
+        snprintf(ctx->parentStack[ctx->nestDepth], DSGN_MAX_NAME, "%s", name);
+        ctx->nestDepth++;
+    }
+}
+
+
+static void dsgnLoad_onCtrlEnd(void *userData) {
+    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;
+
+    if (ctx->containerDepth > 0) {
+        ctx->containerDepth--;
+
+        if (ctx->containerStack[ctx->containerDepth] && ctx->nestDepth > 0) {
+            ctx->nestDepth--;
+        }
+    }
+
+    ctx->current = NULL;
+}
+
+
+static void dsgnLoad_onCtrlProp(void *userData, const char *key, const char *value) {
+    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;
+
+    if (!ctx->current) {
+        return;
+    }
+
+    char val[DSGN_MAX_TEXT];
+    snprintf(val, sizeof(val), "%s", value);
+    frmStripQuotes(val);
+
+    DsgnControlT *cc = ctx->current;
+
+    if (strcasecmp(key, "Left") == 0) {
+        cc->left = atoi(val);
+    } else if (strcasecmp(key, "Top") == 0) {
+        cc->top = atoi(val);
+    } else if (strcasecmp(key, "MinWidth") == 0 || strcasecmp(key, "Width") == 0) {
+        cc->width = atoi(val);
+    } else if (strcasecmp(key, "MinHeight") == 0 || strcasecmp(key, "Height") == 0) {
+        cc->height = atoi(val);
+    } else if (strcasecmp(key, "MaxWidth") == 0) {
+        cc->maxWidth = atoi(val);
+    } else if (strcasecmp(key, "MaxHeight") == 0) {
+        cc->maxHeight = atoi(val);
+    } else if (strcasecmp(key, "Weight") == 0) {
+        cc->weight = atoi(val);
+    } else if (strcasecmp(key, "Index") == 0) {
+        cc->index = atoi(val);
+    } else if (strcasecmp(key, "HelpTopic") == 0) {
+        snprintf(cc->helpTopic, DSGN_MAX_NAME, "%s", val);
+    } else if (strcasecmp(key, "TabIndex") == 0) {
+        // ignored -- DVX has no tab order
+    } else {
+        setPropValue(cc, key, val);
+    }
+}
+
+
+static bool dsgnLoad_onFormBegin(void *userData, const char *name) {
+    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;
+
+    if (!ctx->form) {
+        return false;
+    }
+
+    snprintf(ctx->form->name, DSGN_MAX_NAME, "%s", name);
+    snprintf(ctx->form->caption, DSGN_MAX_TEXT, "%s", name);
+    ctx->current   = NULL;
+    ctx->nestDepth = 0;
+    return true;
+}
+
+
+static void dsgnLoad_onFormEnd(void *userData, const char *trailingSrc, int32_t trailingLen) {
+    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;
+
+    if (!ctx->form || trailingLen <= 0) {
+        return;
+    }
+
+    // Skip leading whitespace/blank lines
+    const char *codeStart = trailingSrc;
+    const char *codeEnd   = trailingSrc + trailingLen;
+
+    while (codeStart < codeEnd && (*codeStart == '\r' || *codeStart == '\n' || *codeStart == ' ' || *codeStart == '\t')) {
+        codeStart++;
+    }
+
+    if (codeStart >= codeEnd) {
+        return;
+    }
+
+    int32_t codeLen = (int32_t)(codeEnd - codeStart);
+    ctx->form->code = (char *)malloc(codeLen + 1);
+
+    if (ctx->form->code) {
+        memcpy(ctx->form->code, codeStart, codeLen);
+        ctx->form->code[codeLen] = '\0';
+    }
+}
+
+
+static void dsgnLoad_onFormProp(void *userData, const char *key, const char *value) {
+    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;
+
+    if (!ctx->form) {
+        return;
+    }
+
+    char val[DSGN_MAX_TEXT];
+    snprintf(val, sizeof(val), "%s", value);
+    frmStripQuotes(val);
+
+    DsgnFormT *ff = ctx->form;
+
+    if (strcasecmp(key, "Caption") == 0) {
+        snprintf(ff->caption, DSGN_MAX_TEXT, "%s", val);
+    } else if (strcasecmp(key, "Layout") == 0) {
+        strncpy(ff->layout, val, DSGN_MAX_NAME - 1);
+        ff->layout[DSGN_MAX_NAME - 1] = '\0';
+    } else if (strcasecmp(key, "AutoSize") == 0) {
+        ff->autoSize = frmParseBool(val);
+    } else if (strcasecmp(key, "Resizable") == 0) {
+        ff->resizable = frmParseBool(val);
+    } else if (strcasecmp(key, "Centered") == 0) {
+        ff->centered = frmParseBool(val);
+    } else if (strcasecmp(key, "Left") == 0) {
+        ff->left = atoi(val);
+    } else if (strcasecmp(key, "Top") == 0) {
+        ff->top = atoi(val);
+    } else if (strcasecmp(key, "Width") == 0) {
+        ff->width    = atoi(val);
+        ff->autoSize = false;
+    } else if (strcasecmp(key, "Height") == 0) {
+        ff->height   = atoi(val);
+        ff->autoSize = false;
+    } else if (strcasecmp(key, "HelpTopic") == 0) {
+        snprintf(ff->helpTopic, DSGN_MAX_NAME, "%s", val);
+    }
+}
+
+
+static void dsgnLoad_onMenuBegin(void *userData, const char *name, int32_t level) {
+    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;
+
+    if (!ctx->form) {
+        return;
+    }
+
+    DsgnMenuItemT mi;
+    memset(&mi, 0, sizeof(mi));
+    snprintf(mi.name, DSGN_MAX_NAME, "%s", name);
+    mi.level   = level;
+    mi.enabled = true;
+    arrput(ctx->form->menuItems, mi);
+    ctx->curMenuItemIdx = (int32_t)arrlen(ctx->form->menuItems) - 1;
+    ctx->current        = NULL;
+}
+
+
+static void dsgnLoad_onMenuEnd(void *userData) {
+    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;
+
+    ctx->curMenuItemIdx = -1;
+}
+
+
+static void dsgnLoad_onMenuProp(void *userData, const char *key, const char *value) {
+    DsgnFrmLoadCtxT *ctx = (DsgnFrmLoadCtxT *)userData;
+
+    if (!ctx->form ||
+        ctx->curMenuItemIdx < 0 ||
+        ctx->curMenuItemIdx >= (int32_t)arrlen(ctx->form->menuItems)) {
+        return;
+    }
+
+    // Resolve pointer fresh each write -- arrput on nested menus may
+    // have reallocated the array.
+    DsgnMenuItemT *mip = &ctx->form->menuItems[ctx->curMenuItemIdx];
+    char           val[DSGN_MAX_TEXT];
+    snprintf(val, sizeof(val), "%s", value);
+    frmStripQuotes(val);
+
+    if (strcasecmp(key, "Caption") == 0) {
+        snprintf(mip->caption, DSGN_MAX_TEXT, "%s", val);
+    } else if (strcasecmp(key, "Checked") == 0) {
+        mip->checked = frmParseBool(val);
+    } else if (strcasecmp(key, "RadioCheck") == 0) {
+        mip->radioCheck = frmParseBool(val);
+    } else if (strcasecmp(key, "Enabled") == 0) {
+        mip->enabled = frmParseBool(val);
+    }
+}
+
+
 void dsgnNewForm(DsgnStateT *ds, const char *name) {
     dsgnFree(ds);
 
@@ -1218,21 +1187,6 @@ static void rebuildWidgets(DsgnStateT *ds) {
 }
 
 
-static const char *resolveTypeName(const char *typeName) {
-    const char *wgtName = wgtFindByBasName(typeName);
-
-    if (wgtName) {
-        return wgtName;
-    }
-
-    if (wgtGetApi(typeName)) {
-        return typeName;
-    }
-
-    return NULL;
-}
-
-
 // Write controls at a given nesting level with the specified parent name.
 static int32_t saveControls(const DsgnFormT *form, char *buf, int32_t bufSize, int32_t pos, const char *parentName, int32_t indent) {
     int32_t count = (int32_t)arrlen(form->controls);
@@ -1318,26 +1272,17 @@ static int32_t saveControls(const DsgnFormT *form, char *buf, int32_t bufSize, i
                         continue;
                     }
 
-                    if (p->type == WGT_IFACE_ENUM && p->enumNames) {
-                        int32_t v = ((int32_t (*)(const WidgetT *))p->getFn)(ctrl->widget);
-                        const char *name = NULL;
+                    // Skip STRING props here: saveControls only emits
+                    // the iface-known scalar types.  String values come
+                    // through ctrl->props[] (custom props).
+                    if (p->type == WGT_IFACE_STRING) {
+                        continue;
+                    }
 
-                        for (int32_t en = 0; p->enumNames[en]; en++) {
-                            if (en == v) {
-                                name = p->enumNames[en];
-                                break;
-                            }
-                        }
+                    char valBuf[DSGN_MAX_TEXT];
 
-                        if (name) {
-                            pos += snprintf(buf + pos, bufSize - pos, "%s    %s = %s\n", pad, p->name, name);
-                        }
-                    } else if (p->type == WGT_IFACE_INT) {
-                        int32_t v = ((int32_t (*)(const WidgetT *))p->getFn)(ctrl->widget);
-                        pos += snprintf(buf + pos, bufSize - pos, "%s    %s = %d\n", pad, p->name, (int)v);
-                    } else if (p->type == WGT_IFACE_BOOL) {
-                        bool v = ((bool (*)(const WidgetT *))p->getFn)(ctrl->widget);
-                        pos += snprintf(buf + pos, bufSize - pos, "%s    %s = %s\n", pad, p->name, v ? "True" : "False");
+                    if (wgtPropValueToString(ctrl->widget, p, valBuf, sizeof(valBuf))) {
+                        pos += snprintf(buf + pos, bufSize - pos, "%s    %s = %s\n", pad, p->name, valBuf);
                     }
                 }
             }
diff --git a/src/apps/kpunch/dvxbasic/ide/ideMain.c b/src/apps/kpunch/dvxbasic/ide/ideMain.c
index 60a5205..2cf61a4 100644
--- a/src/apps/kpunch/dvxbasic/ide/ideMain.c
+++ b/src/apps/kpunch/dvxbasic/ide/ideMain.c
@@ -69,6 +69,7 @@
 #include "../compiler/strip.h"
 #include "../runtime/serialize.h"
 #include "../formrt/formrt.h"
+#include "../formrt/frmParser.h"
 #include "dvxRes.h"
 #include "../../../../libs/kpunch/sql/dvxSql.h"
 #include "../runtime/vm.h"
@@ -1425,6 +1426,254 @@ static void compileAndRun(void) {
 }
 
 
+// ============================================================
+// Compile-time CtrlName.Member validator
+// ============================================================
+//
+// The IDE (unlike bascomp) has widget DXEs loaded, so wgtGetIface
+// returns live interface metadata.  Combined with a static scan of
+// the project's .frm files (control name -> widget type), we can
+// reject typos like GfxCanvas.Boggle or LblStatus.Caphtion at
+// compile time instead of letting them surface as runtime errors
+// at event-click time.  Dynamically-created controls (via
+// CreateControl at runtime) aren't in the map; lookupCtrlType
+// returns NULL for them and validation is skipped.
+
+typedef struct {
+    char name[BAS_MAX_CTRL_NAME];
+    char wgtType[BAS_MAX_CTRL_NAME]; // "Form" for the form itself, else iface basName
+} IdeCtrlMapEntryT;
+
+
+typedef struct {
+    IdeCtrlMapEntryT *entries; // stb_ds dynamic array
+} IdeValidatorCtxT;
+
+
+static bool ideValidator_onFormBegin(void *ud, const char *name) {
+    IdeValidatorCtxT *v = (IdeValidatorCtxT *)ud;
+    IdeCtrlMapEntryT e;
+    memset(&e, 0, sizeof(e));
+    snprintf(e.name,    BAS_MAX_CTRL_NAME, "%s", name);
+    snprintf(e.wgtType, BAS_MAX_CTRL_NAME, "%s", "Form");
+    arrput(v->entries, e);
+    return true;
+}
+
+
+static void ideValidator_onCtrlBegin(void *ud, const char *typeName, const char *name) {
+    IdeValidatorCtxT *v = (IdeValidatorCtxT *)ud;
+    IdeCtrlMapEntryT e;
+    memset(&e, 0, sizeof(e));
+    snprintf(e.name,    BAS_MAX_CTRL_NAME, "%s", name);
+    snprintf(e.wgtType, BAS_MAX_CTRL_NAME, "%s", typeName ? typeName : "");
+    arrput(v->entries, e);
+}
+
+
+static void ideValidator_onMenuBegin(void *ud, const char *name, int32_t level) {
+    (void)level;
+    IdeValidatorCtxT *v = (IdeValidatorCtxT *)ud;
+    IdeCtrlMapEntryT e;
+    memset(&e, 0, sizeof(e));
+    snprintf(e.name,    BAS_MAX_CTRL_NAME, "%s", name);
+    snprintf(e.wgtType, BAS_MAX_CTRL_NAME, "%s", "Menu");
+    arrput(v->entries, e);
+}
+
+
+static const char *ideValidator_lookupCtrlType(void *ctx, const char *ctrlName) {
+    IdeValidatorCtxT *v = (IdeValidatorCtxT *)ctx;
+
+    if (!v || !ctrlName) {
+        return NULL;
+    }
+
+    for (int32_t i = 0; i < (int32_t)arrlen(v->entries); i++) {
+        if (strcasecmp(v->entries[i].name, ctrlName) == 0) {
+            return v->entries[i].wgtType;
+        }
+    }
+
+    return NULL;
+}
+
+
+// Methods that exist on every widget via callCommonMethod() in formrt.c.
+static bool ideValidator_isCommonMethod(const char *methodName) {
+    return strcasecmp(methodName, "SetFocus")   == 0 ||
+           strcasecmp(methodName, "Refresh")    == 0 ||
+           strcasecmp(methodName, "SetReadOnly") == 0 ||
+           strcasecmp(methodName, "SetEnabled") == 0 ||
+           strcasecmp(methodName, "SetVisible") == 0;
+}
+
+
+// Properties that setProp/getProp accept on every non-form, non-menu
+// widget (common props + widget text/data/help aliases).
+static bool ideValidator_isCommonProp(const char *propName) {
+    return strcasecmp(propName, "Name")       == 0 ||
+           strcasecmp(propName, "Left")       == 0 ||
+           strcasecmp(propName, "Top")        == 0 ||
+           strcasecmp(propName, "Width")      == 0 ||
+           strcasecmp(propName, "Height")     == 0 ||
+           strcasecmp(propName, "MinWidth")   == 0 ||
+           strcasecmp(propName, "MinHeight")  == 0 ||
+           strcasecmp(propName, "MaxWidth")   == 0 ||
+           strcasecmp(propName, "MaxHeight")  == 0 ||
+           strcasecmp(propName, "Weight")     == 0 ||
+           strcasecmp(propName, "Visible")    == 0 ||
+           strcasecmp(propName, "Enabled")    == 0 ||
+           strcasecmp(propName, "Caption")    == 0 ||
+           strcasecmp(propName, "Text")       == 0 ||
+           strcasecmp(propName, "HelpTopic")  == 0 ||
+           strcasecmp(propName, "DataSource") == 0 ||
+           strcasecmp(propName, "DataField")  == 0 ||
+           strcasecmp(propName, "ListCount")  == 0;
+}
+
+
+static bool ideValidator_isMethodValid(void *ctx, const char *wgtType, const char *methodName) {
+    (void)ctx;
+
+    if (!wgtType || !methodName) {
+        return true; // be permissive on malformed input
+    }
+
+    if (ideValidator_isCommonMethod(methodName)) {
+        return true;
+    }
+
+    // Form-level methods
+    if (strcasecmp(wgtType, "Form") == 0) {
+        return strcasecmp(methodName, "Show") == 0 ||
+               strcasecmp(methodName, "Hide") == 0;
+    }
+
+    // Menu items have no methods beyond common
+    if (strcasecmp(wgtType, "Menu") == 0) {
+        return false;
+    }
+
+    // Widget-specific methods from the live iface
+    const char *wgtName = wgtFindByBasName(wgtType);
+
+    if (!wgtName) {
+        return true; // unknown type -- skip validation
+    }
+
+    const WgtIfaceT *iface = wgtGetIface(wgtName);
+
+    if (!iface || !iface->methods) {
+        return true;
+    }
+
+    for (int32_t i = 0; i < iface->methodCount; i++) {
+        if (strcasecmp(iface->methods[i].name, methodName) == 0) {
+            return true;
+        }
+    }
+
+    return false;
+}
+
+
+static bool ideValidator_isPropValid(void *ctx, const char *wgtType, const char *propName) {
+    (void)ctx;
+
+    if (!wgtType || !propName) {
+        return true;
+    }
+
+    // Form-level properties
+    if (strcasecmp(wgtType, "Form") == 0) {
+        return strcasecmp(propName, "Name")       == 0 ||
+               strcasecmp(propName, "Caption")    == 0 ||
+               strcasecmp(propName, "Width")      == 0 ||
+               strcasecmp(propName, "Height")     == 0 ||
+               strcasecmp(propName, "Left")       == 0 ||
+               strcasecmp(propName, "Top")        == 0 ||
+               strcasecmp(propName, "Visible")    == 0 ||
+               strcasecmp(propName, "Resizable")  == 0 ||
+               strcasecmp(propName, "AutoSize")   == 0 ||
+               strcasecmp(propName, "Centered")   == 0 ||
+               strcasecmp(propName, "Layout")     == 0;
+    }
+
+    // Menu items
+    if (strcasecmp(wgtType, "Menu") == 0) {
+        return strcasecmp(propName, "Name")    == 0 ||
+               strcasecmp(propName, "Checked") == 0 ||
+               strcasecmp(propName, "Enabled") == 0 ||
+               strcasecmp(propName, "Caption") == 0;
+    }
+
+    if (ideValidator_isCommonProp(propName)) {
+        return true;
+    }
+
+    // Widget-specific props from the live iface
+    const char *wgtName = wgtFindByBasName(wgtType);
+
+    if (!wgtName) {
+        return true;
+    }
+
+    const WgtIfaceT *iface = wgtGetIface(wgtName);
+
+    if (!iface || !iface->props) {
+        return true;
+    }
+
+    for (int32_t i = 0; i < iface->propCount; i++) {
+        if (strcasecmp(iface->props[i].name, propName) == 0) {
+            return true;
+        }
+    }
+
+    return false;
+}
+
+
+// Walk every .frm in the project and populate a (name -> wgtType) map
+// the parser can consult.  Caller must arrfree(ctx->entries) when done.
+static void ideBuildCtrlMap(IdeValidatorCtxT *ctx) {
+    ctx->entries = NULL;
+
+    FrmParserCbsT cbs;
+    memset(&cbs, 0, sizeof(cbs));
+    cbs.userData     = ctx;
+    cbs.onFormBegin  = ideValidator_onFormBegin;
+    cbs.onCtrlBegin  = ideValidator_onCtrlBegin;
+    cbs.onMenuBegin  = ideValidator_onMenuBegin;
+
+    for (int32_t i = 0; i < sProject.fileCount; i++) {
+        if (!sProject.files[i].isForm) {
+            continue;
+        }
+
+        // Use the buffered source if the file is open in an editor,
+        // otherwise load from disk.  Mirrors how the designer loads.
+        char *diskBuf   = NULL;
+        const char *src = sProject.files[i].buffer;
+        int32_t len     = src ? (int32_t)strlen(src) : 0;
+
+        if (!src) {
+            int32_t dlen = 0;
+            diskBuf = platformReadFile(sProject.files[i].path, &dlen);
+            src = diskBuf;
+            len = dlen;
+        }
+
+        if (src && len > 0) {
+            frmParse(src, len, &cbs);
+        }
+
+        free(diskBuf);
+    }
+}
+
+
 static bool compileProject(void) {
     // Save all dirty files before compiling if Save on Run is enabled
     if (sWin && sWin->menuBar && wmMenuItemIsChecked(sWin->menuBar, CMD_SAVE_ON_RUN)) {
@@ -1647,7 +1896,22 @@ static bool compileProject(void) {
     }
 
     basParserInit(parser, src, srcLen);
-    parser->optionExplicit = prefsGetBool(sPrefs, "editor", "optionExplicit", false);
+    parser->optionExplicit = sProject.optionExplicit;
+
+    // Build a name -> widget-type map from the project's .frm files
+    // and attach a validator so the parser can reject CtrlName.Member
+    // typos at compile time.  The validator falls back silently for
+    // dynamically-created controls (not in the map).
+    IdeValidatorCtxT validatorCtx;
+    memset(&validatorCtx, 0, sizeof(validatorCtx));
+    ideBuildCtrlMap(&validatorCtx);
+
+    BasCtrlValidatorT validator;
+    validator.lookupCtrlType = ideValidator_lookupCtrlType;
+    validator.isMethodValid  = ideValidator_isMethodValid;
+    validator.isPropValid    = ideValidator_isPropValid;
+    validator.ctx            = &validatorCtx;
+    basParserSetValidator(parser, &validator);
 
     if (!basParse(parser)) {
         // Translate global error line to local file/line for display
@@ -1715,10 +1979,12 @@ static bool compileProject(void) {
         basParserFree(parser);
         free(parser);
         free(concatBuf);
+        arrfree(validatorCtx.entries);
         return false;
     }
 
     free(concatBuf);
+    arrfree(validatorCtx.entries);
 
     BasModuleT *mod = basParserBuildModule(parser);
     basParserFree(parser);
@@ -2062,19 +2328,15 @@ static void dsgnCopySelected(void) {
                     continue;
                 }
 
-                if (p->type == WGT_IFACE_ENUM && p->enumNames) {
-                    int32_t v = ((int32_t (*)(const WidgetT *))p->getFn)(ctrl->widget);
-                    const char *name = (v >= 0 && p->enumNames[v]) ? p->enumNames[v] : NULL;
+                // Skip STRING props -- custom props handle those.
+                if (p->type == WGT_IFACE_STRING) {
+                    continue;
+                }
 
-                    if (name) {
-                        pos += snprintf(buf + pos, sizeof(buf) - pos, "    %s = %s\n", p->name, name);
-                    }
-                } else if (p->type == WGT_IFACE_INT) {
-                    int32_t v = ((int32_t (*)(const WidgetT *))p->getFn)(ctrl->widget);
-                    pos += snprintf(buf + pos, sizeof(buf) - pos, "    %s = %d\n", p->name, (int)v);
-                } else if (p->type == WGT_IFACE_BOOL) {
-                    bool v = ((bool (*)(const WidgetT *))p->getFn)(ctrl->widget);
-                    pos += snprintf(buf + pos, sizeof(buf) - pos, "    %s = %s\n", p->name, v ? "True" : "False");
+                char valBuf[DSGN_MAX_TEXT];
+
+                if (wgtPropValueToString(ctrl->widget, p, valBuf, sizeof(valBuf))) {
+                    pos += snprintf(buf + pos, sizeof(buf) - pos, "    %s = %s\n", p->name, valBuf);
                 }
             }
         }
@@ -2305,23 +2567,8 @@ static void dsgnPasteControl(void) {
                     }
                 }
 
-                if (!val) {
-                    continue;
-                }
-
-                if (p->type == WGT_IFACE_ENUM && p->enumNames) {
-                    for (int32_t en = 0; p->enumNames[en]; en++) {
-                        if (strcasecmp(p->enumNames[en], val) == 0) {
-                            ((void (*)(WidgetT *, int32_t))p->setFn)(ctrl.widget, en);
-                            break;
-                        }
-                    }
-                } else if (p->type == WGT_IFACE_INT) {
-                    ((void (*)(WidgetT *, int32_t))p->setFn)(ctrl.widget, atoi(val));
-                } else if (p->type == WGT_IFACE_BOOL) {
-                    ((void (*)(WidgetT *, bool))p->setFn)(ctrl.widget, strcasecmp(val, "True") == 0);
-                } else if (p->type == WGT_IFACE_STRING) {
-                    ((void (*)(WidgetT *, const char *))p->setFn)(ctrl.widget, val);
+                if (val) {
+                    wgtApplyPropFromString(ctrl.widget, p, val);
                 }
             }
         }
@@ -8077,7 +8324,7 @@ static void showPreferencesDialog(void) {
     sPrefsDlg.renameSkipComments = wgtCheckbox(edFrame, "Skip comments/strings when renaming");
     wgtCheckboxSetChecked(sPrefsDlg.renameSkipComments, prefsGetBool(sPrefs, "editor", "renameSkipComments", true));
 
-    sPrefsDlg.optionExplicit = wgtCheckbox(edFrame, "Require variable declaration (OPTION EXPLICIT)");
+    sPrefsDlg.optionExplicit = wgtCheckbox(edFrame, "OPTION EXPLICIT default for new projects");
     wgtCheckboxSetChecked(sPrefsDlg.optionExplicit, prefsGetBool(sPrefs, "editor", "optionExplicit", false));
 
     WidgetT *tabRow = wgtHBox(edFrame);
diff --git a/src/apps/kpunch/dvxbasic/ide/ideProject.c b/src/apps/kpunch/dvxbasic/ide/ideProject.c
index b7e4853..b8379ec 100644
--- a/src/apps/kpunch/dvxbasic/ide/ideProject.c
+++ b/src/apps/kpunch/dvxbasic/ide/ideProject.c
@@ -51,6 +51,7 @@
 #include "dvxWm.h"
 #include "box/box.h"
 #include "button/button.h"
+#include "checkbox/checkbox.h"
 #include "dropdown/dropdown.h"
 #include "image/image.h"
 #include "label/label.h"
@@ -98,6 +99,7 @@ static struct {
     WidgetT      *copyright;
     WidgetT      *description;
     WidgetT      *startupForm;
+    WidgetT      *optionExplicit;
     const char  **formNames;    // stb_ds array of form name strings for startup dropdown
     WidgetT      *helpFileInput;
     WidgetT      *iconPreview;
@@ -522,6 +524,8 @@ bool prjLoad(PrjStateT *prj, const char *dbpPath) {
     val = prefsGetString(h, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_STARTUPFORM, NULL);
     if (val) { snprintf(prj->startupForm, sizeof(prj->startupForm), "%s", val); }
 
+    prj->optionExplicit = prefsGetBool(h, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_OPTIONEXPLICIT, false);
+
     prefsClose(h);
     prj->dirty = false;
     return true;
@@ -608,6 +612,7 @@ void prjNew(PrjStateT *prj, const char *name, const char *directory, PrefsHandle
         snprintf(prj->version, sizeof(prj->version), "%s", prefsGetString(prefs, "defaults", "version", "1.0"));
         snprintf(prj->copyright, sizeof(prj->copyright), "%s", prefsGetString(prefs, "defaults", "copyright", ""));
         snprintf(prj->description, sizeof(prj->description), "%s", prefsGetString(prefs, "defaults", "description", ""));
+        prj->optionExplicit = prefsGetBool(prefs, "editor", "optionExplicit", false);
     }
 }
 
@@ -617,7 +622,7 @@ bool prjPropertiesDialog(AppContextT *ctx, PrjStateT *prj, const char *appPath)
         return false;
     }
 
-    WindowT *win = dvxCreateWindowCentered(ctx, "Project Properties", PPD_WIDTH, 380, false);
+    WindowT *win = dvxCreateWindowCentered(ctx, "Project Properties", PPD_WIDTH, 410, false);
 
     if (!win) {
         return false;
@@ -739,6 +744,10 @@ bool prjPropertiesDialog(AppContextT *ctx, PrjStateT *prj, const char *appPath)
         hlpBrowse->onClick = ppdOnBrowseHelp;
     }
 
+    // Compiler options: OPTION EXPLICIT enforces DIM-before-use for this project.
+    sPpd.optionExplicit = wgtCheckbox(root, "Require variable declaration (OPTION EXPLICIT)");
+    wgtCheckboxSetChecked(sPpd.optionExplicit, prj->optionExplicit);
+
     // Description: label above, textarea below (matches Preferences layout)
     wgtLabel(root, "Description:");
     sPpd.description = wgtTextArea(root, PRJ_MAX_DESC);
@@ -804,6 +813,7 @@ bool prjPropertiesDialog(AppContextT *ctx, PrjStateT *prj, const char *appPath)
             }
         }
 
+        prj->optionExplicit = wgtCheckboxIsChecked(sPpd.optionExplicit);
         prj->dirty = true;
     }
 
@@ -948,6 +958,7 @@ bool prjSave(const PrjStateT *prj) {
 
     // [Settings] section
     prefsSetString(h, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_STARTUPFORM, prj->startupForm);
+    prefsSetBool(h, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_OPTIONEXPLICIT, prj->optionExplicit);
 
     bool ok = prefsSaveAs(h, prj->projectPath);
     prefsClose(h);
diff --git a/src/apps/kpunch/dvxbasic/ide/ideProject.h b/src/apps/kpunch/dvxbasic/ide/ideProject.h
index 91a2405..5621150 100644
--- a/src/apps/kpunch/dvxbasic/ide/ideProject.h
+++ b/src/apps/kpunch/dvxbasic/ide/ideProject.h
@@ -79,6 +79,7 @@ typedef struct {
     char            description[PRJ_MAX_DESC];
     char            iconPath[DVX_MAX_PATH];    // relative path to icon BMP
     char            helpFile[DVX_MAX_PATH];    // relative path to .hlp file
+    bool            optionExplicit;            // require DIM before use
     PrjFileT       *files;                      // stb_ds dynamic array
     int32_t         fileCount;
     PrjSourceMapT  *sourceMap;                  // stb_ds dynamic array
diff --git a/src/apps/kpunch/dvxbasic/ide/ideProperties.c b/src/apps/kpunch/dvxbasic/ide/ideProperties.c
index 93ef7ff..85190be 100644
--- a/src/apps/kpunch/dvxbasic/ide/ideProperties.c
+++ b/src/apps/kpunch/dvxbasic/ide/ideProperties.c
@@ -28,6 +28,7 @@
 // property value to edit it via an InputBox dialog.
 
 #include "ideProperties.h"
+#include "../formrt/frmParser.h"
 #include "dvxDlg.h"
 #include "dvxWm.h"
 #include "box/box.h"
@@ -616,7 +617,7 @@ static void onPropDblClick(WidgetT *w) {
 
     if (propType == PROP_TYPE_BOOL) {
         // Toggle boolean on double-click -- no input box
-        bool cur = (strcasecmp(curValue, "True") == 0);
+        bool cur = frmParseBool(curValue);
         snprintf(newValue, sizeof(newValue), "%s", cur ? "False" : "True");
     } else if (propType == PROP_TYPE_ENUM) {
         // Enum: cycle to next value on double-click
@@ -923,7 +924,7 @@ static void onPropDblClick(WidgetT *w) {
                 ctrl->widget->weight = ctrl->weight;
             }
         } else if (strcasecmp(propName, "Visible") == 0) {
-            bool val = (strcasecmp(newValue, "True") == 0);
+            bool val = frmParseBool(newValue);
 
             if (ctrl->widget) {
                 wgtSetVisible(ctrl->widget, val);
@@ -934,7 +935,7 @@ static void onPropDblClick(WidgetT *w) {
                 cascadeToChildren(sDs, ctrl->name, val, en);
             }
         } else if (strcasecmp(propName, "Enabled") == 0) {
-            bool val = (strcasecmp(newValue, "True") == 0);
+            bool val = frmParseBool(newValue);
 
             if (ctrl->widget) {
                 wgtSetEnabled(ctrl->widget, val);
@@ -965,7 +966,9 @@ static void onPropDblClick(WidgetT *w) {
                             }
 
                             if (p->type == WGT_IFACE_STRING) {
-                                // Store in props for persistence, set from there
+                                // Strings must outlive this function, so the
+                                // ctrl->props[] copy is what we pass to setFn
+                                // (not the newValue buffer).
                                 bool found = false;
 
                                 for (int32_t j = 0; j < ctrl->propCount; j++) {
@@ -983,21 +986,8 @@ static void onPropDblClick(WidgetT *w) {
                                     ((void (*)(WidgetT *, const char *))p->setFn)(ctrl->widget, ctrl->props[ctrl->propCount].value);
                                     ctrl->propCount++;
                                 }
-                            } else if (p->type == WGT_IFACE_ENUM && p->enumNames) {
-                                int32_t enumVal = 0;
-
-                                for (int32_t en = 0; p->enumNames[en]; en++) {
-                                    if (strcasecmp(p->enumNames[en], newValue) == 0) {
-                                        enumVal = en;
-                                        break;
-                                    }
-                                }
-
-                                ((void (*)(WidgetT *, int32_t))p->setFn)(ctrl->widget, enumVal);
-                            } else if (p->type == WGT_IFACE_INT) {
-                                ((void (*)(WidgetT *, int32_t))p->setFn)(ctrl->widget, atoi(newValue));
-                            } else if (p->type == WGT_IFACE_BOOL) {
-                                ((void (*)(WidgetT *, bool))p->setFn)(ctrl->widget, strcasecmp(newValue, "True") == 0);
+                            } else {
+                                wgtApplyPropFromString(ctrl->widget, p, newValue);
                             }
 
                             ifaceHandled = true;
@@ -1068,7 +1058,7 @@ static void onPropDblClick(WidgetT *w) {
                 dvxSetTitle(sPrpCtx, sDs->formWin, winTitle);
             }
         } else if (strcasecmp(propName, "AutoSize") == 0) {
-            sDs->form->autoSize = (strcasecmp(newValue, "True") == 0);
+            sDs->form->autoSize = frmParseBool(newValue);
 
             if (sDs->form->autoSize && sDs->formWin) {
                 dvxFitWindow(sPrpCtx, sDs->formWin);
@@ -1076,14 +1066,14 @@ static void onPropDblClick(WidgetT *w) {
                 sDs->form->height = sDs->formWin->h;
             }
         } else if (strcasecmp(propName, "Resizable") == 0) {
-            sDs->form->resizable = (strcasecmp(newValue, "True") == 0);
+            sDs->form->resizable = frmParseBool(newValue);
 
             if (sDs->formWin) {
                 sDs->formWin->resizable = sDs->form->resizable;
                 dvxInvalidateWindow(sPrpCtx, sDs->formWin);
             }
         } else if (strcasecmp(propName, "Centered") == 0) {
-            sDs->form->centered = (strcasecmp(newValue, "True") == 0);
+            sDs->form->centered = frmParseBool(newValue);
         } else if (strcasecmp(propName, "Left") == 0) {
             sDs->form->left = atoi(newValue);
         } else if (strcasecmp(propName, "Top") == 0) {
@@ -1473,29 +1463,14 @@ void prpRefresh(DsgnStateT *ds) {
                         continue;
                     }
 
-                    // Read the current value from the widget
-                    if (p->type == WGT_IFACE_STRING && p->getFn) {
-                        const char *s = ((const char *(*)(WidgetT *))p->getFn)(ctrl->widget);
-                        addPropRow(p->name, s ? s : "");
-                    } else if (p->type == WGT_IFACE_ENUM && p->getFn && p->enumNames) {
-                        int32_t v = ((int32_t (*)(const WidgetT *))p->getFn)(ctrl->widget);
-                        const char *name = NULL;
+                    // Read the current value from the widget.  Enum values
+                    // that don't map to a name are shown as "?".
+                    char valBuf[DSGN_MAX_TEXT];
 
-                        for (int32_t k = 0; p->enumNames[k]; k++) {
-                            if (k == v) {
-                                name = p->enumNames[k];
-                                break;
-                            }
-                        }
-
-                        addPropRow(p->name, name ? name : "?");
-                    } else if (p->type == WGT_IFACE_INT && p->getFn) {
-                        int32_t v = ((int32_t (*)(const WidgetT *))p->getFn)(ctrl->widget);
-                        snprintf(buf, sizeof(buf), "%d", (int)v);
-                        addPropRow(p->name, buf);
-                    } else if (p->type == WGT_IFACE_BOOL && p->getFn) {
-                        bool v = ((bool (*)(const WidgetT *))p->getFn)(ctrl->widget);
-                        addPropRow(p->name, v ? "True" : "False");
+                    if (wgtPropValueToString(ctrl->widget, p, valBuf, sizeof(valBuf))) {
+                        addPropRow(p->name, valBuf);
+                    } else if (p->type == WGT_IFACE_ENUM) {
+                        addPropRow(p->name, "?");
                     } else {
                         addPropRow(p->name, "");
                     }
diff --git a/src/apps/kpunch/dvxbasic/runtime/serialize.c b/src/apps/kpunch/dvxbasic/runtime/serialize.c
index f1d146a..25f1cac 100644
--- a/src/apps/kpunch/dvxbasic/runtime/serialize.c
+++ b/src/apps/kpunch/dvxbasic/runtime/serialize.c
@@ -274,6 +274,10 @@ BasModuleT *basModuleDeserialize(const uint8_t *data, int32_t dataLen) {
             char *name = rStr(&r);
             snprintf(p->name, BAS_MAX_PROC_NAME, "%s", name);
             free(name);
+
+            char *formName = rStr(&r);
+            snprintf(p->formName, BAS_MAX_PROC_NAME, "%s", formName);
+            free(formName);
         }
     }
 
@@ -294,6 +298,19 @@ BasModuleT *basModuleDeserialize(const uint8_t *data, int32_t dataLen) {
         }
     }
 
+    // Global-slot runtime type init table (survives strip).  STRING
+    // globals need this so first-use string ops work correctly.
+    mod->globalInitCount = rI32(&r);
+
+    if (mod->globalInitCount > 0) {
+        mod->globalInits = (BasGlobalInitT *)calloc(mod->globalInitCount, sizeof(BasGlobalInitT));
+
+        for (int32_t i = 0; i < mod->globalInitCount; i++) {
+            mod->globalInits[i].index    = rI32(&r);
+            mod->globalInits[i].dataType = rU8(&r);
+        }
+    }
+
     return mod;
 }
 
@@ -323,6 +340,7 @@ void basModuleFree(BasModuleT *mod) {
 
     free(mod->procs);
     free(mod->formVarInfo);
+    free(mod->globalInits);
     free(mod->debugVars);
 
     if (mod->debugUdtDefs) {
@@ -411,6 +429,7 @@ uint8_t *basModuleSerialize(const BasModuleT *mod, int32_t *outLen) {
         bufWriteU8(&b, p->returnType);
         bufWriteU8(&b, p->isFunction ? 1 : 0);
         bufWriteStr(&b, p->name);
+        bufWriteStr(&b, p->formName);
     }
 
     // Form variable info (runtime-essential for per-form variable allocation)
@@ -424,6 +443,14 @@ uint8_t *basModuleSerialize(const BasModuleT *mod, int32_t *outLen) {
         bufWriteI32(&b, fv->initCodeLen);
     }
 
+    // Global init table (runtime-essential for STRING default slot type)
+    bufWriteI32(&b, mod->globalInitCount);
+
+    for (int32_t i = 0; i < mod->globalInitCount; i++) {
+        bufWriteI32(&b, mod->globalInits[i].index);
+        bufWriteU8(&b, mod->globalInits[i].dataType);
+    }
+
     *outLen = b.len;
     return b.buf;
 }
diff --git a/src/apps/kpunch/dvxbasic/runtime/values.c b/src/apps/kpunch/dvxbasic/runtime/values.c
index d4e8599..694da68 100644
--- a/src/apps/kpunch/dvxbasic/runtime/values.c
+++ b/src/apps/kpunch/dvxbasic/runtime/values.c
@@ -68,11 +68,19 @@ void basUdtFree(BasUdtT *udt);
 BasUdtT *basUdtNew(int32_t typeId, int32_t fieldCount);
 BasUdtT *basUdtRef(BasUdtT *udt);
 void basUdtUnref(BasUdtT *udt);
+BasValueT basValBool(bool v);
 int32_t basValCompare(BasValueT a, BasValueT b);
 int32_t basValCompareCI(BasValueT a, BasValueT b);
+BasValueT basValCopy(BasValueT v);
+BasValueT basValDouble(double v);
 BasStringT *basValFormatString(BasValueT v);
+BasValueT basValInteger(int16_t v);
 bool basValIsTruthy(BasValueT v);
+BasValueT basValLong(int32_t v);
+BasValueT basValObject(void *obj);
 uint8_t basValPromoteType(uint8_t a, uint8_t b);
+void basValRelease(BasValueT *v);
+BasValueT basValSingle(float v);
 BasValueT basValString(BasStringT *s);
 BasValueT basValStringFromC(const char *text);
 BasValueT basValToBool(BasValueT v);
@@ -399,8 +407,16 @@ void basUdtUnref(BasUdtT *udt) {
 
 
 // ============================================================
-// Value constructors  (trivial ones moved to values.h as static inline)
+// Value constructors / refcount helpers
 // ============================================================
+BasValueT basValBool(bool v) {
+    BasValueT val;
+    val.type    = BAS_TYPE_BOOLEAN;
+    val.boolVal = v ? -1 : 0;
+    return val;
+}
+
+
 int32_t basValCompare(BasValueT a, BasValueT b) {
     // String comparison
     if (a.type == BAS_TYPE_STRING && b.type == BAS_TYPE_STRING) {
@@ -445,6 +461,27 @@ int32_t basValCompareCI(BasValueT a, BasValueT b) {
 }
 
 
+BasValueT basValCopy(BasValueT v) {
+    if (v.type == BAS_TYPE_STRING && v.strVal) {
+        basStringRef(v.strVal);
+    } else if (v.type == BAS_TYPE_ARRAY && v.arrVal) {
+        basArrayRef(v.arrVal);
+    } else if (v.type == BAS_TYPE_UDT && v.udtVal) {
+        basUdtRef(v.udtVal);
+    }
+
+    return v;
+}
+
+
+BasValueT basValDouble(double v) {
+    BasValueT val;
+    val.type   = BAS_TYPE_DOUBLE;
+    val.dblVal = v;
+    return val;
+}
+
+
 BasStringT *basValFormatString(BasValueT v) {
     char buf[64];
 
@@ -478,6 +515,14 @@ BasStringT *basValFormatString(BasValueT v) {
 }
 
 
+BasValueT basValInteger(int16_t v) {
+    BasValueT val;
+    val.type   = BAS_TYPE_INTEGER;
+    val.intVal = v;
+    return val;
+}
+
+
 bool basValIsTruthy(BasValueT v) {
     switch (v.type) {
         case BAS_TYPE_INTEGER:
@@ -504,6 +549,22 @@ bool basValIsTruthy(BasValueT v) {
 }
 
 
+BasValueT basValLong(int32_t v) {
+    BasValueT val;
+    val.type    = BAS_TYPE_LONG;
+    val.longVal = v;
+    return val;
+}
+
+
+BasValueT basValObject(void *obj) {
+    BasValueT val;
+    val.type   = BAS_TYPE_OBJECT;
+    val.objVal = obj;
+    return val;
+}
+
+
 uint8_t basValPromoteType(uint8_t a, uint8_t b) {
     // String stays string (concat, not arithmetic)
     if (a == BAS_TYPE_STRING || b == BAS_TYPE_STRING) {
@@ -529,6 +590,28 @@ uint8_t basValPromoteType(uint8_t a, uint8_t b) {
 }
 
 
+void basValRelease(BasValueT *v) {
+    if (v->type == BAS_TYPE_STRING) {
+        basStringUnref(v->strVal);
+        v->strVal = NULL;
+    } else if (v->type == BAS_TYPE_ARRAY) {
+        basArrayUnref(v->arrVal);
+        v->arrVal = NULL;
+    } else if (v->type == BAS_TYPE_UDT) {
+        basUdtUnref(v->udtVal);
+        v->udtVal = NULL;
+    }
+}
+
+
+BasValueT basValSingle(float v) {
+    BasValueT val;
+    val.type   = BAS_TYPE_SINGLE;
+    val.sngVal = v;
+    return val;
+}
+
+
 BasValueT basValString(BasStringT *s) {
     BasValueT val;
     val.type   = BAS_TYPE_STRING;
diff --git a/src/apps/kpunch/dvxbasic/runtime/values.h b/src/apps/kpunch/dvxbasic/runtime/values.h
index f300faa..6214ee5 100644
--- a/src/apps/kpunch/dvxbasic/runtime/values.h
+++ b/src/apps/kpunch/dvxbasic/runtime/values.h
@@ -31,8 +31,6 @@
 #ifndef DVXBASIC_VALUES_H
 #define DVXBASIC_VALUES_H
 
-#include "../compiler/opcodes.h"   // BAS_TYPE_*
-
 #include 
 #include 
 #include 
@@ -159,89 +157,22 @@ struct BasValueTag {
     };
 };
 
-// Create values -- trivial constructors inlined so they're fast in vm.c's
-// hot path (PUSH_INT16, PUSH_TRUE, etc.).
-static inline BasValueT basValInteger(int16_t v) {
-    BasValueT val;
-    val.type   = BAS_TYPE_INTEGER;
-    val.intVal = v;
-    return val;
-}
-
-
-static inline BasValueT basValLong(int32_t v) {
-    BasValueT val;
-    val.type    = BAS_TYPE_LONG;
-    val.longVal = v;
-    return val;
-}
-
-
-static inline BasValueT basValSingle(float v) {
-    BasValueT val;
-    val.type   = BAS_TYPE_SINGLE;
-    val.sngVal = v;
-    return val;
-}
-
-
-static inline BasValueT basValDouble(double v) {
-    BasValueT val;
-    val.type   = BAS_TYPE_DOUBLE;
-    val.dblVal = v;
-    return val;
-}
-
-
-static inline BasValueT basValBool(bool v) {
-    BasValueT val;
-    val.type    = BAS_TYPE_BOOLEAN;
-    val.boolVal = v ? -1 : 0;
-    return val;
-}
-
-
-static inline BasValueT basValObject(void *obj) {
-    BasValueT val;
-    val.type   = BAS_TYPE_OBJECT;
-    val.objVal = obj;
-    return val;
-}
-
-
+// Create values.  Out-of-line so they can be resolved via the DXE
+// dynamic-symbol table when basrt.lib is loaded.
+BasValueT basValInteger(int16_t v);
+BasValueT basValLong(int32_t v);
+BasValueT basValSingle(float v);
+BasValueT basValDouble(double v);
+BasValueT basValBool(bool v);
+BasValueT basValObject(void *obj);
 BasValueT basValString(BasStringT *s);
 BasValueT basValStringFromC(const char *text);
 
 // Copy a value (increments string/array/udt refcount if applicable).
-// Inlined so the hot-path case (integer/float/bool) is a single struct
-// return -- no function call, no branch beyond the type test.
-static inline BasValueT basValCopy(BasValueT v) {
-    if (v.type == BAS_TYPE_STRING && v.strVal) {
-        basStringRef(v.strVal);
-    } else if (v.type == BAS_TYPE_ARRAY && v.arrVal) {
-        basArrayRef(v.arrVal);
-    } else if (v.type == BAS_TYPE_UDT && v.udtVal) {
-        basUdtRef(v.udtVal);
-    }
+BasValueT basValCopy(BasValueT v);
 
-    return v;
-}
-
-
-// Release a value (decrements refcount if applicable).  Integer/float/bool
-// types are a no-op -- the common case is an immediately-returning branch.
-static inline void basValRelease(BasValueT *v) {
-    if (v->type == BAS_TYPE_STRING) {
-        basStringUnref(v->strVal);
-        v->strVal = NULL;
-    } else if (v->type == BAS_TYPE_ARRAY) {
-        basArrayUnref(v->arrVal);
-        v->arrVal = NULL;
-    } else if (v->type == BAS_TYPE_UDT) {
-        basUdtUnref(v->udtVal);
-        v->udtVal = NULL;
-    }
-}
+// Release a value (decrements refcount if applicable).
+void basValRelease(BasValueT *v);
 
 // Convert a value to a specific type. Returns the converted value.
 // The original is NOT released -- caller manages lifetime.
diff --git a/src/apps/kpunch/dvxbasic/runtime/vm.c b/src/apps/kpunch/dvxbasic/runtime/vm.c
index 6492dcf..82bb006 100644
--- a/src/apps/kpunch/dvxbasic/runtime/vm.c
+++ b/src/apps/kpunch/dvxbasic/runtime/vm.c
@@ -97,8 +97,9 @@ bool basVmCallSub(BasVmT *vm, int32_t codeAddr) {
     // Push a call frame that returns to an invalid address (sentinel)
     // We detect completion when callDepth drops back to savedCallDepth
     BasCallFrameT *frame = &vm->callStack[vm->callDepth++];
-    frame->returnPc   = savedPc;
-    frame->localCount = BAS_VM_MAX_LOCALS;
+    frame->returnPc     = savedPc;
+    frame->localCount   = BAS_VM_MAX_LOCALS;
+    frame->errorHandler = 0;
     memset(frame->locals, 0, sizeof(frame->locals));
 
     // Jump to the SUB
@@ -131,8 +132,9 @@ bool basVmCallSubWithArgs(BasVmT *vm, int32_t codeAddr, const BasValueT *args, i
     bool    savedRunning   = vm->running;
 
     BasCallFrameT *frame = &vm->callStack[vm->callDepth++];
-    frame->returnPc   = savedPc;
-    frame->localCount = BAS_VM_MAX_LOCALS;
+    frame->returnPc     = savedPc;
+    frame->localCount   = BAS_VM_MAX_LOCALS;
+    frame->errorHandler = 0;
     memset(frame->locals, 0, sizeof(frame->locals));
 
     // Set arguments as locals (parameter 0 = local 0, etc.)
@@ -169,8 +171,9 @@ bool basVmCallSubWithArgsOut(BasVmT *vm, int32_t codeAddr, const BasValueT *args
     bool    savedRunning   = vm->running;
 
     BasCallFrameT *frame = &vm->callStack[vm->callDepth++];
-    frame->returnPc   = savedPc;
-    frame->localCount = BAS_VM_MAX_LOCALS;
+    frame->returnPc     = savedPc;
+    frame->localCount   = BAS_VM_MAX_LOCALS;
+    frame->errorHandler = 0;
     memset(frame->locals, 0, sizeof(frame->locals));
 
     for (int32_t i = 0; i < argCount && i < BAS_VM_MAX_LOCALS; i++) {
@@ -269,6 +272,28 @@ const char *basVmGetError(const BasVmT *vm) {
 void basVmLoadModule(BasVmT *vm, BasModuleT *module) {
     vm->module = module;
     vm->pc     = module->entryPoint;
+
+    // Initialize globals whose declared type requires a non-numeric
+    // default.  Uninitialized slots are zero (type=INTEGER, value=0),
+    // which breaks STRING operations: `DIM s AS STRING : s = s + "x"`
+    // would do numeric addition instead of concatenation.  STATIC
+    // variables also alias global slots, so the same issue applies.
+    // globalInits survives the compiler's strip pass, unlike debugVars.
+    if (module->globalInits) {
+        for (int32_t i = 0; i < module->globalInitCount; i++) {
+            BasGlobalInitT *g = &module->globalInits[i];
+
+            if (g->index < 0 || g->index >= BAS_VM_MAX_GLOBALS) {
+                continue;
+            }
+
+            if (g->dataType == BAS_TYPE_STRING) {
+                basValRelease(&vm->globals[g->index]);
+                vm->globals[g->index].type   = BAS_TYPE_STRING;
+                vm->globals[g->index].strVal = basStringNew("", 0);
+            }
+        }
+    }
 }
 
 
@@ -325,13 +350,40 @@ BasVmResultE basVmRun(BasVmT *vm) {
 
         if (result != BAS_VM_OK) {
             // If an error handler is set and this is a trappable error,
-            // jump to the handler instead of stopping execution
-            if (vm->errorHandler != 0 && !vm->inErrorHandler && result != BAS_VM_HALTED && result != BAS_VM_BAD_OPCODE) {
-                vm->errorPc        = savedPc;
-                vm->errorNextPc    = vm->pc;
-                vm->inErrorHandler = true;
-                vm->pc             = vm->errorHandler;
-                continue;
+            // unwind call frames until we find the SUB whose ON ERROR
+            // GOTO registered a handler, then jump there.  Without this
+            // unwind an error raised inside a called SUB would fire the
+            // outer SUB's handler but OP_RET would then pop the inner
+            // frame and resume after the call site (effectively RESUME
+            // NEXT semantics), which is not what ON ERROR promises.
+            if (!vm->inErrorHandler && result != BAS_VM_HALTED && result != BAS_VM_BAD_OPCODE) {
+                int32_t target = 0;
+
+                while (vm->callDepth > 0) {
+                    BasCallFrameT *frame = &vm->callStack[vm->callDepth - 1];
+
+                    if (frame->errorHandler != 0) {
+                        target = frame->errorHandler;
+                        break;
+                    }
+
+                    // No handler on this frame -- discard it and keep
+                    // unwinding.  frame-local values need releasing.
+                    for (int32_t li = 0; li < frame->localCount; li++) {
+                        basValRelease(&frame->locals[li]);
+                    }
+
+                    vm->callDepth--;
+                }
+
+                if (target != 0) {
+                    vm->errorPc        = savedPc;
+                    vm->errorNextPc    = vm->pc;
+                    vm->inErrorHandler = true;
+                    vm->errorHandler   = target;
+                    vm->pc             = target;
+                    continue;
+                }
             }
 
             vm->running = false;
@@ -840,8 +892,9 @@ BasVmResultE basVmStep(BasVmT *vm) {
             }
 
             BasCallFrameT *frame = &vm->callStack[vm->callDepth++];
-            frame->returnPc   = vm->pc;
-            frame->localCount = BAS_VM_MAX_LOCALS;
+            frame->returnPc     = vm->pc;
+            frame->localCount   = BAS_VM_MAX_LOCALS;
+            frame->errorHandler = 0;
 
             // Zero all local slots
             memset(frame->locals, 0, sizeof(frame->locals));
@@ -853,6 +906,9 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 }
             }
 
+            // The callee starts with no handler; if it raises an error
+            // the dispatcher will unwind to the nearest frame with one.
+            vm->errorHandler = 0;
             vm->pc = addr;
             break;
         }
@@ -870,7 +926,24 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&frame->locals[i]);
             }
 
+            bool hadHandler = (frame->errorHandler != 0);
+            frame->errorHandler = 0;
             vm->pc = frame->returnPc;
+
+            // Restore the active handler to whatever the caller had set
+            BasCallFrameT *caller = currentFrame(vm);
+            vm->errorHandler = caller ? caller->errorHandler : 0;
+
+            // If this SUB owned the active error handler, any handler
+            // body that ran inside it is now done -- clear the flag so
+            // the next error (in this or another SUB) can trap again.
+            // QBASIC documents this: an unresumed handler is cleared
+            // when the procedure that installed it returns.
+            if (hadHandler) {
+                vm->inErrorHandler = false;
+                vm->errorNumber    = 0;
+                vm->errorMsg[0]    = '\0';
+            }
             break;
         }
 
@@ -894,8 +967,19 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&frame->locals[i]);
             }
 
+            bool hadHandler = (frame->errorHandler != 0);
+            frame->errorHandler = 0;
             vm->pc = frame->returnPc;
 
+            BasCallFrameT *caller = currentFrame(vm);
+            vm->errorHandler = caller ? caller->errorHandler : 0;
+
+            if (hadHandler) {
+                vm->inErrorHandler = false;
+                vm->errorNumber    = 0;
+                vm->errorMsg[0]    = '\0';
+            }
+
             if (!push(vm, retVal)) {
                 basValRelease(&retVal);
                 return BAS_VM_STACK_OVERFLOW;
@@ -918,8 +1002,9 @@ BasVmResultE basVmStep(BasVmT *vm) {
         }
 
         case OP_FOR_INIT: {
-            uint16_t varIdx   = readUint16(vm);
-            uint8_t  scopeTag = readUint8(vm);
+            uint16_t varIdx     = readUint16(vm);
+            uint8_t  scopeTag   = readUint8(vm);
+            int16_t  skipOffset = readInt16(vm);
             BasValueT stepVal;
             BasValueT limitVal;
 
@@ -940,6 +1025,43 @@ BasVmResultE basVmStep(BasVmT *vm) {
             fs->limit   = limitVal;
             fs->step    = stepVal;
             fs->loopTop = vm->pc;
+
+            // Entry check: if the loop's range is already empty (e.g.
+            // FOR i = 10 TO 5 with positive step), skip the body and
+            // pop the FOR state.  QBASIC semantics: body executes zero
+            // times when the range is empty.
+            BasValueT *varSlot = NULL;
+
+            if (scopeTag == SCOPE_LOCAL) {
+                BasCallFrameT *frame = currentFrame(vm);
+
+                if (frame && varIdx < (uint16_t)frame->localCount) {
+                    varSlot = &frame->locals[varIdx];
+                }
+            } else if (scopeTag == SCOPE_FORM) {
+                if (vm->currentFormVars && varIdx < (uint16_t)vm->currentFormVarCount) {
+                    varSlot = &vm->currentFormVars[varIdx];
+                }
+            } else {
+                if (varIdx < BAS_VM_MAX_GLOBALS) {
+                    varSlot = &vm->globals[varIdx];
+                }
+            }
+
+            if (varSlot) {
+                double curVal = basValToNumber(*varSlot);
+                double stepNum = basValToNumber(fs->step);
+                double limNum  = basValToNumber(fs->limit);
+                bool   enter   = (stepNum >= 0) ? (curVal <= limNum) : (curVal >= limNum);
+
+                if (!enter) {
+                    basValRelease(&fs->limit);
+                    basValRelease(&fs->step);
+                    vm->forDepth--;
+                    vm->pc += skipOffset;
+                }
+            }
+
             break;
         }
 
@@ -990,7 +1112,11 @@ BasVmResultE basVmStep(BasVmT *vm) {
 
             // Increment: var = var + step. Preserve the loop variable's
             // original type so extern calls that pass it as an int32_t
-            // argument don't get an 8-byte double instead.
+            // argument don't get an 8-byte double instead.  Exception:
+            // if STEP is fractional, an integer slot would truncate the
+            // increment and spin forever (FOR a = 0 TO 6.3 STEP 0.2 with
+            // `a` stored as INTEGER from the literal 0).  Promote to
+            // DOUBLE in that case so the loop can actually progress.
             double  varVal  = basValToNumber(*varSlot);
             double  stepVal = basValToNumber(fs->step);
             double  limVal  = basValToNumber(fs->limit);
@@ -999,7 +1125,11 @@ BasVmResultE basVmStep(BasVmT *vm) {
 
             basValRelease(varSlot);
 
-            if (varType == BAS_TYPE_INTEGER) {
+            bool stepIsFractional = (stepVal != floor(stepVal));
+
+            if (stepIsFractional) {
+                *varSlot = basValDouble(varVal);
+            } else if (varType == BAS_TYPE_INTEGER) {
                 *varSlot = basValInteger((int16_t)varVal);
             } else if (varType == BAS_TYPE_LONG) {
                 *varSlot = basValLong((int32_t)varVal);
@@ -1639,6 +1769,40 @@ BasVmResultE basVmStep(BasVmT *vm) {
             break;
         }
 
+        case OP_STR_OCT: {
+            if (vm->sp < 1) {
+                return BAS_VM_STACK_UNDERFLOW;
+            }
+
+            BasValueT *top = &vm->stack[vm->sp - 1];
+            int32_t n = (int32_t)basValToNumber(*top);
+            char buf[16];
+            snprintf(buf, sizeof(buf), "%o", (unsigned int)n);
+            basValRelease(top);
+            *top = basValStringFromC(buf);
+            break;
+        }
+
+        case OP_CONV_BOOL: {
+            if (vm->sp < 1) {
+                return BAS_VM_STACK_UNDERFLOW;
+            }
+
+            BasValueT *top = &vm->stack[vm->sp - 1];
+            bool truthy;
+
+            if (top->type == BAS_TYPE_STRING) {
+                truthy = (top->strVal && top->strVal->len > 0);
+            } else {
+                truthy = (basValToNumber(*top) != 0.0);
+            }
+
+            basValRelease(top);
+            top->type    = BAS_TYPE_BOOLEAN;
+            top->boolVal = truthy ? -1 : 0;
+            break;
+        }
+
         case OP_STR_STRING: {
             // STRING$(n, char)
             BasValueT charVal;
@@ -1827,7 +1991,19 @@ BasVmResultE basVmStep(BasVmT *vm) {
 
         case OP_ON_ERROR: {
             int16_t handler = readInt16(vm);
-            vm->errorHandler = (handler == 0) ? 0 : vm->pc + handler;
+            int32_t target  = (handler == 0) ? 0 : vm->pc + handler;
+
+            // Record the handler on the current call frame.  The error
+            // dispatcher walks frames (innermost outward) to find a
+            // handler and unwinds the stack to that frame before
+            // jumping.  Module-level code lives in frame 0.
+            BasCallFrameT *frame = currentFrame(vm);
+
+            if (frame) {
+                frame->errorHandler = target;
+            }
+
+            vm->errorHandler = target;
             break;
         }
 
@@ -2032,6 +2208,58 @@ BasVmResultE basVmStep(BasVmT *vm) {
             break;
         }
 
+        case OP_PUSH_ARR_ADDR: {
+            // Pass `arr(i)` as a BYREF parameter: push a BAS_TYPE_REF
+            // pointing into the array's element storage.  The array is
+            // ref-counted, and the caller still holds a reference via
+            // the local it came from, so the element memory is stable
+            // for the duration of the call.
+            uint8_t dims = readUint8(vm);
+            int32_t indices[BAS_ARRAY_MAX_DIMS];
+
+            for (int32_t d = dims - 1; d >= 0; d--) {
+                BasValueT idxVal;
+
+                if (!pop(vm, &idxVal)) {
+                    return BAS_VM_STACK_UNDERFLOW;
+                }
+
+                indices[d] = (int32_t)basValToNumber(idxVal);
+                basValRelease(&idxVal);
+            }
+
+            BasValueT arrRef;
+
+            if (!pop(vm, &arrRef)) {
+                return BAS_VM_STACK_UNDERFLOW;
+            }
+
+            if (arrRef.type != BAS_TYPE_ARRAY || !arrRef.arrVal) {
+                basValRelease(&arrRef);
+                runtimeError(vm, 13, "Not an array");
+                return BAS_VM_TYPE_MISMATCH;
+            }
+
+            int32_t flatIdx = basArrayIndex(arrRef.arrVal, indices, dims);
+
+            if (flatIdx < 0) {
+                basValRelease(&arrRef);
+                runtimeError(vm, 9, "Subscript out of range");
+                return BAS_VM_SUBSCRIPT_RANGE;
+            }
+
+            BasValueT ref;
+            ref.type   = BAS_TYPE_REF;
+            ref.refVal = &arrRef.arrVal->elements[flatIdx];
+            basValRelease(&arrRef);
+
+            if (!push(vm, ref)) {
+                return BAS_VM_STACK_OVERFLOW;
+            }
+
+            break;
+        }
+
         case OP_STORE_ARRAY: {
             uint8_t dims = readUint8(vm);
 
@@ -3623,6 +3851,15 @@ static BasVmResultE execArith(BasVmT *vm, uint8_t op) {
         }
     }
 
+    // Capture operand types BEFORE release so we know whether to keep
+    // the result as a float.  The parser always emits OP_ADD_INT (the
+    // VM promotes based on operand types), so the opcode alone can't
+    // tell us whether this is an integer or floating-point op.
+    uint8_t aType = a.type;
+    uint8_t bType = b.type;
+    bool    hadFloat = (aType == BAS_TYPE_SINGLE || aType == BAS_TYPE_DOUBLE ||
+                        bType == BAS_TYPE_SINGLE || bType == BAS_TYPE_DOUBLE);
+
     double na = basValToNumber(a);
     double nb = basValToNumber(b);
     basValRelease(&a);
@@ -3682,8 +3919,12 @@ static BasVmResultE execArith(BasVmT *vm, uint8_t op) {
             break;
     }
 
-    // Return appropriate type
-    if (op == OP_ADD_INT || op == OP_SUB_INT || op == OP_MUL_INT || op == OP_IDIV_INT || op == OP_MOD_INT) {
+    // Return appropriate type.  An op that would normally produce an
+    // integer result (OP_ADD_INT, etc.) still has to yield a float when
+    // either operand was a float -- otherwise 1.5 + 2.25 truncates to 3.
+    bool intOp = (op == OP_ADD_INT || op == OP_SUB_INT || op == OP_MUL_INT || op == OP_IDIV_INT || op == OP_MOD_INT);
+
+    if (intOp && !hadFloat) {
         if (result >= (double)INT16_MIN && result <= (double)INT16_MAX) {
             push(vm, basValInteger((int16_t)result));
         } else if (result >= (double)INT32_MIN && result <= (double)INT32_MAX) {
@@ -5333,17 +5574,19 @@ static bool push(BasVmT *vm, BasValueT val) {
 }
 
 
-// x86 tolerates unaligned loads at cost of ~1 cycle; cast-through-pointer is
-// faster than memcpy because the compiler can emit a single 16-bit load.
+// memcpy with constant size is folded to a single load by the compiler and
+// is alignment-safe (bytecode operands aren't guaranteed 2-byte aligned).
 static inline int16_t readInt16(BasVmT *vm) {
-    int16_t val = *(const int16_t *)&vm->module->code[vm->pc];
+    int16_t val;
+    memcpy(&val, &vm->module->code[vm->pc], sizeof(int16_t));
     vm->pc += sizeof(int16_t);
     return val;
 }
 
 
 static inline uint16_t readUint16(BasVmT *vm) {
-    uint16_t val = *(const uint16_t *)&vm->module->code[vm->pc];
+    uint16_t val;
+    memcpy(&val, &vm->module->code[vm->pc], sizeof(uint16_t));
     vm->pc += sizeof(uint16_t);
     return val;
 }
@@ -5364,6 +5607,7 @@ static bool runSubLoop(BasVmT *vm, int32_t savedPc, int32_t savedCallDepth, bool
     bool    hadBreakpoint   = false;
 
     while (vm->running && vm->callDepth > savedCallDepth) {
+        int32_t stepPc = vm->pc;  // save for ON ERROR dispatch
         BasVmResultE result = basVmStep(vm);
 
         if (result == BAS_VM_HALTED) {
@@ -5397,6 +5641,41 @@ static bool runSubLoop(BasVmT *vm, int32_t savedPc, int32_t savedCallDepth, bool
         }
 
         if (result != BAS_VM_OK) {
+            // Try ON ERROR GOTO: walk call frames inside the current
+            // sub-call boundary (savedCallDepth) looking for one that
+            // registered a handler.  If found, unwind to that frame
+            // and jump to the handler.  Mirrors basVmRun's dispatcher
+            // so event handlers (fired via basVmCallSub) behave like
+            // module-level code when it comes to error trapping.
+            if (!vm->inErrorHandler && result != BAS_VM_BAD_OPCODE) {
+                int32_t target = 0;
+
+                while (vm->callDepth > savedCallDepth) {
+                    BasCallFrameT *frame = &vm->callStack[vm->callDepth - 1];
+
+                    if (frame->errorHandler != 0) {
+                        target = frame->errorHandler;
+                        break;
+                    }
+
+                    for (int32_t li = 0; li < frame->localCount; li++) {
+                        basValRelease(&frame->locals[li]);
+                    }
+
+                    vm->callDepth--;
+                }
+
+                if (target != 0) {
+                    vm->errorPc        = stepPc;
+                    vm->errorNextPc    = vm->pc;
+                    vm->inErrorHandler = true;
+                    vm->errorHandler   = target;
+                    vm->pc             = target;
+                    stepsSinceYield    = 0;
+                    continue;
+                }
+            }
+
             vm->pc        = savedPc;
             vm->callDepth = savedCallDepth;
             vm->running   = savedRunning;
diff --git a/src/apps/kpunch/dvxbasic/runtime/vm.h b/src/apps/kpunch/dvxbasic/runtime/vm.h
index 3d1bc18..4fa9946 100644
--- a/src/apps/kpunch/dvxbasic/runtime/vm.h
+++ b/src/apps/kpunch/dvxbasic/runtime/vm.h
@@ -262,6 +262,7 @@ typedef struct {
     int32_t   returnPc;               // instruction to return to
     int32_t   baseSlot;               // base index in locals array
     int32_t   localCount;             // number of locals in this frame
+    int32_t   errorHandler;           // ON ERROR GOTO target in this SUB (0 = none)
     BasValueT locals[BAS_VM_MAX_LOCALS];
 } BasCallFrameT;
 
@@ -293,12 +294,13 @@ typedef struct {
 #define BAS_MAX_PROC_NAME 64
 
 typedef struct {
-    char    name[BAS_MAX_PROC_NAME]; // SUB/FUNCTION name (case-preserved)
-    int32_t codeAddr;                // entry point in code[]
-    int32_t paramCount;              // number of parameters
-    int32_t localCount;              // number of local variables (for debugger)
-    uint8_t returnType;              // BAS_TYPE_* (0 for SUB)
-    bool    isFunction;              // true = FUNCTION, false = SUB
+    char    name[BAS_MAX_PROC_NAME];     // SUB/FUNCTION name (case-preserved)
+    char    formName[BAS_MAX_PROC_NAME]; // owning form (for form-scope vars), "" if global
+    int32_t codeAddr;                    // entry point in code[]
+    int32_t paramCount;                  // number of parameters
+    int32_t localCount;                  // number of local variables (for debugger)
+    uint8_t returnType;                  // BAS_TYPE_* (0 for SUB)
+    bool    isFunction;                  // true = FUNCTION, false = SUB
 } BasProcEntryT;
 
 // Debug UDT field definition (preserved for debugger watch)
@@ -340,6 +342,16 @@ typedef struct {
 // Compiled module (output of the compiler)
 // ============================================================
 
+// Runtime-required global init entry.  STRING and SINGLE/DOUBLE
+// globals need to start with the correct slot type even when debug
+// info has been stripped, or operators that switch on slot type
+// (e.g. STRING concat) break on first use.
+typedef struct {
+    int32_t  index;     // global slot index
+    uint8_t  dataType;  // BAS_TYPE_*
+} BasGlobalInitT;
+
+
 typedef struct {
     uint8_t          *code;             // p-code bytecode
     int32_t           codeLen;
@@ -353,6 +365,8 @@ typedef struct {
     int32_t           procCount;
     BasFormVarInfoT  *formVarInfo;      // per-form variable counts
     int32_t           formVarInfoCount;
+    BasGlobalInitT   *globalInits;      // runtime global type init (survives stripping)
+    int32_t           globalInitCount;
     BasDebugVarT     *debugVars;       // variable names for debugger
     int32_t           debugVarCount;
     BasDebugUdtDefT  *debugUdtDefs;   // UDT type definitions for debugger
diff --git a/src/apps/kpunch/dvxbasic/stub/bascomp.c b/src/apps/kpunch/dvxbasic/stub/bascomp.c
index 03df193..6026a29 100644
--- a/src/apps/kpunch/dvxbasic/stub/bascomp.c
+++ b/src/apps/kpunch/dvxbasic/stub/bascomp.c
@@ -183,6 +183,7 @@ int main(int argc, char **argv) {
     const char *helpFile    = prefsGetString(prefs, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_HELPFILE, "");
     const char *startupForm = prefsGetString(prefs, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_STARTUPFORM, "");
     (void)startupForm; // used implicitly by stub's basFormRtLoadAllForms
+    bool optionExplicit = prefsGetBool(prefs, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_OPTIONEXPLICIT, false);
 
     // Derive output path
     char outBuf[DVX_MAX_PATH];
@@ -355,6 +356,7 @@ int main(int argc, char **argv) {
     }
 
     basParserInit(parser, concatBuf, pos);
+    parser->optionExplicit = optionExplicit;
 
     if (!basParse(parser)) {
         fprintf(stderr, "Compile error at line %d: %s\n", (int)parser->errorLine, parser->error);
diff --git a/src/apps/kpunch/dvxbasic/stub/basstub.c b/src/apps/kpunch/dvxbasic/stub/basstub.c
index 8cd9851..f036d76 100644
--- a/src/apps/kpunch/dvxbasic/stub/basstub.c
+++ b/src/apps/kpunch/dvxbasic/stub/basstub.c
@@ -170,10 +170,15 @@ int32_t appMain(DxeAppContextT *ctx) {
     basVmSetInputCallback(vm, stubInput, NULL);
     basVmSetDoEventsCallback(vm, stubDoEvents, NULL);
 
-    // Set app paths
-    snprintf(vm->appPath, DVX_MAX_PATH, "%s", ctx->appDir);
-    snprintf(vm->appConfig, DVX_MAX_PATH, "%s", ctx->appDir);
-    snprintf(vm->appData, DVX_MAX_PATH, "%s", ctx->appDir);
+    // Set app paths.  App.Path is the .app's directory (read-only on CD);
+    // App.Config and App.Data are writable subdirectories created on
+    // demand.  The IDE does the same split for project debugging, so
+    // behavior matches between compiled apps and in-IDE runs.
+    snprintf(vm->appPath,   DVX_MAX_PATH, "%s", ctx->appDir);
+    snprintf(vm->appConfig, DVX_MAX_PATH, "%s" DVX_PATH_SEP "CONFIG", ctx->appDir);
+    snprintf(vm->appData,   DVX_MAX_PATH, "%s" DVX_PATH_SEP "DATA",   ctx->appDir);
+    platformMkdirRecursive(vm->appConfig);
+    platformMkdirRecursive(vm->appData);
 
     // Set extern call callbacks (required for DECLARE LIBRARY functions)
     BasExternCallbacksT extCb;
diff --git a/src/apps/kpunch/dvxbasic/test_suite.c b/src/apps/kpunch/dvxbasic/test_suite.c
new file mode 100644
index 0000000..5753ac4
--- /dev/null
+++ b/src/apps/kpunch/dvxbasic/test_suite.c
@@ -0,0 +1,2254 @@
+// The MIT License (MIT)
+//
+// Copyright (C) 2026 Scott Duensing
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+
+// test_suite.c  -- Assertion-based regression tests for the BASIC
+// language runtime.  Each test compiles a snippet, runs the VM with
+// PRINT output captured, and compares the captured text to an
+// expected string.  Failures are reported with a diff-style preview
+// and the process exits non-zero, so `make tests` surfaces regressions
+// before they reach 86Box.
+//
+// Add new tests with TEST_EQ(name, source, expected) -- other helpers
+// cover compile errors and runtime errors.
+
+#include "compiler/parser.h"
+#include "runtime/vm.h"
+#include "runtime/values.h"
+
+#include 
+#include 
+#include 
+
+// ============================================================
+// Capture buffer
+// ============================================================
+
+#define CAPTURE_MAX 8192
+
+typedef struct {
+    char buf[CAPTURE_MAX];
+    int32_t len;
+} CaptureT;
+
+
+static void captureReset(CaptureT *c) {
+    c->buf[0] = '\0';
+    c->len    = 0;
+}
+
+
+static void captureAppend(CaptureT *c, const char *s) {
+    int32_t n = (int32_t)strlen(s);
+
+    if (c->len + n >= CAPTURE_MAX - 1) {
+        n = CAPTURE_MAX - 1 - c->len;
+    }
+
+    if (n > 0) {
+        memcpy(c->buf + c->len, s, n);
+        c->len += n;
+        c->buf[c->len] = '\0';
+    }
+}
+
+
+static void capturePrint(void *ctx, const char *text, bool newline) {
+    CaptureT *c = (CaptureT *)ctx;
+
+    if (text) {
+        captureAppend(c, text);
+    }
+
+    if (newline) {
+        captureAppend(c, "\n");
+    }
+}
+
+
+// ============================================================
+// Test driver
+// ============================================================
+
+static int32_t sPassCount = 0;
+static int32_t sFailCount = 0;
+
+
+static void reportPass(const char *name) {
+    printf("PASS  %s\n", name);
+    sPassCount++;
+}
+
+
+static void reportFail(const char *name, const char *detail) {
+    printf("FAIL  %s\n", name);
+
+    if (detail && detail[0]) {
+        printf("      %s\n", detail);
+    }
+
+    sFailCount++;
+}
+
+
+static void reportFailDiff(const char *name, const char *expected, const char *got) {
+    printf("FAIL  %s\n", name);
+    printf("      expected: [%s]\n", expected);
+    printf("      got:      [%s]\n", got);
+    sFailCount++;
+}
+
+
+// Compile + run.  On success captures PRINT output into *out.  Returns
+// 0 on success, negative on compile error, positive for runtime errors.
+// Runtime error text goes into outErr if provided.
+static int32_t runAndCapture(const char *source, char *out, int32_t outSize, char *outErr, int32_t outErrSize) {
+    int32_t len = (int32_t)strlen(source);
+
+    BasParserT parser;
+    basParserInit(&parser, source, len);
+
+    if (!basParse(&parser)) {
+        if (outErr) {
+            snprintf(outErr, outErrSize, "%s", parser.error);
+        }
+
+        basParserFree(&parser);
+        return -1;
+    }
+
+    BasModuleT *mod = basParserBuildModule(&parser);
+    basParserFree(&parser);
+
+    if (!mod) {
+        if (outErr) {
+            snprintf(outErr, outErrSize, "module build failed");
+        }
+
+        return -2;
+    }
+
+    BasVmT *vm = basVmCreate();
+    basVmLoadModule(vm, mod);
+
+    CaptureT cap;
+    captureReset(&cap);
+    basVmSetPrintCallback(vm, capturePrint, &cap);
+
+    vm->callStack[0].localCount = mod->globalCount > 64 ? 64 : mod->globalCount;
+    vm->callDepth = 1;
+
+    BasVmResultE result = basVmRun(vm);
+
+    int32_t rc = 0;
+
+    if (result != BAS_VM_HALTED && result != BAS_VM_OK) {
+        if (outErr) {
+            snprintf(outErr, outErrSize, "%s", basVmGetError(vm));
+        }
+
+        rc = (int32_t)result;
+    }
+
+    if (out && outSize > 0) {
+        snprintf(out, outSize, "%s", cap.buf);
+    }
+
+    basVmDestroy(vm);
+    basModuleFree(mod);
+    return rc;
+}
+
+
+// TEST_EQ -- compile, run, expect captured output to match
+static void testEq(const char *name, const char *source, const char *expected) {
+    char out[CAPTURE_MAX];
+    char err[256] = "";
+
+    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));
+
+    if (rc != 0) {
+        char detail[512];
+        snprintf(detail, sizeof(detail), "rc=%d err=%s", (int)rc, err);
+        reportFail(name, detail);
+        return;
+    }
+
+    if (strcmp(out, expected) != 0) {
+        reportFailDiff(name, expected, out);
+        return;
+    }
+
+    reportPass(name);
+}
+
+
+// TEST_COMPILE_ERROR -- expect compilation to fail; substring of the
+// error message must match.  Pass an empty needle to accept any error.
+static void testCompileError(const char *name, const char *source, const char *needle) {
+    char out[CAPTURE_MAX];
+    char err[256] = "";
+
+    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));
+
+    if (rc != -1) {
+        char detail[256];
+        snprintf(detail, sizeof(detail), "expected compile error; got rc=%d output=[%s]", (int)rc, out);
+        reportFail(name, detail);
+        return;
+    }
+
+    if (needle && needle[0] && strstr(err, needle) == NULL) {
+        char detail[512];
+        snprintf(detail, sizeof(detail), "error [%s] did not contain [%s]", err, needle);
+        reportFail(name, detail);
+        return;
+    }
+
+    reportPass(name);
+}
+
+
+// runSubAndCapture -- compile, then invoke a specific named SUB via
+// basVmCallSub.  Mirrors what fireCtrlEvent does for event handlers.
+// Returns 0 if the SUB returned normally, 1 if it returned false
+// (unhandled error), -1 on compile/load error.  Captured PRINT output
+// goes into out; runtime error text (if any) goes into outErr.
+static int32_t runSubAndCapture(const char *source, const char *subName,
+                                char *out, int32_t outSize,
+                                char *outErr, int32_t outErrSize) {
+    int32_t len = (int32_t)strlen(source);
+
+    BasParserT parser;
+    basParserInit(&parser, source, len);
+
+    if (!basParse(&parser)) {
+        if (outErr) {
+            snprintf(outErr, outErrSize, "%s", parser.error);
+        }
+
+        basParserFree(&parser);
+        return -1;
+    }
+
+    BasModuleT *mod = basParserBuildModule(&parser);
+    basParserFree(&parser);
+
+    if (!mod) {
+        if (outErr) {
+            snprintf(outErr, outErrSize, "module build failed");
+        }
+
+        return -1;
+    }
+
+    BasVmT *vm = basVmCreate();
+    basVmLoadModule(vm, mod);
+
+    CaptureT cap;
+    captureReset(&cap);
+    basVmSetPrintCallback(vm, capturePrint, &cap);
+
+    vm->callStack[0].localCount = mod->globalCount > 64 ? 64 : mod->globalCount;
+    vm->callDepth = 1;
+
+    // Execute module-level code first (to initialize globals)
+    basVmRun(vm);
+
+    // Find the target SUB by name
+    int32_t subAddr = -1;
+
+    for (int32_t i = 0; i < mod->procCount; i++) {
+        if (strcasecmp(mod->procs[i].name, subName) == 0) {
+            subAddr = mod->procs[i].codeAddr;
+            break;
+        }
+    }
+
+    int32_t rc = 0;
+
+    if (subAddr < 0) {
+        if (outErr) {
+            snprintf(outErr, outErrSize, "SUB '%s' not found", subName);
+        }
+        rc = -1;
+    } else {
+        // Clear error state before the call so we see only this call's errors
+        vm->errorNumber    = 0;
+        vm->errorMsg[0]    = '\0';
+        vm->inErrorHandler = false;
+        vm->errorHandler   = 0;
+
+        bool ok = basVmCallSub(vm, subAddr);
+        rc = ok ? 0 : 1;
+
+        if (!ok && outErr) {
+            snprintf(outErr, outErrSize, "%s", basVmGetError(vm));
+        }
+    }
+
+    if (out && outSize > 0) {
+        snprintf(out, outSize, "%s", cap.buf);
+    }
+
+    basVmDestroy(vm);
+    basModuleFree(mod);
+    return rc;
+}
+
+
+// TEST_SUB_EQ -- compile, run module-level code, then invoke a named
+// SUB through basVmCallSub (the path used by event handlers), and
+// compare captured output.
+static void testSubEq(const char *name, const char *source, const char *subName, const char *expected) {
+    char out[CAPTURE_MAX];
+    char err[256] = "";
+
+    int32_t rc = runSubAndCapture(source, subName, out, sizeof(out), err, sizeof(err));
+
+    if (rc < 0) {
+        char detail[512];
+        snprintf(detail, sizeof(detail), "setup error: %s", err);
+        reportFail(name, detail);
+        return;
+    }
+
+    if (rc != 0) {
+        char detail[512];
+        snprintf(detail, sizeof(detail), "SUB returned false (unhandled error): %s", err);
+        reportFail(name, detail);
+        return;
+    }
+
+    if (strcmp(out, expected) != 0) {
+        reportFailDiff(name, expected, out);
+        return;
+    }
+
+    reportPass(name);
+}
+
+
+#define TEST_SUB_EQ(n, s, sub, e)  testSubEq((n), (s), (sub), (e))
+
+
+// TEST_RUNTIME_ERROR -- expect runtime error; optional substring match
+// on the error message.
+static void testRuntimeError(const char *name, const char *source, const char *needle) {
+    char out[CAPTURE_MAX];
+    char err[256] = "";
+
+    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));
+
+    if (rc <= 0) {
+        char detail[256];
+        snprintf(detail, sizeof(detail), "expected runtime error; got rc=%d output=[%s]", (int)rc, out);
+        reportFail(name, detail);
+        return;
+    }
+
+    if (needle && needle[0] && strstr(err, needle) == NULL) {
+        char detail[512];
+        snprintf(detail, sizeof(detail), "error [%s] did not contain [%s]", err, needle);
+        reportFail(name, detail);
+        return;
+    }
+
+    reportPass(name);
+}
+
+
+#define TEST_EQ(n, s, e)             testEq((n), (s), (e))
+#define TEST_COMPILE_ERROR(n, s, e)  testCompileError((n), (s), (e))
+#define TEST_RUNTIME_ERROR(n, s, e)  testRuntimeError((n), (s), (e))
+
+
+// ============================================================
+// Test cases
+// ============================================================
+
+int main(void) {
+    basStringSystemInit();
+
+    printf("DVX BASIC Regression Suite\n");
+    printf("==========================\n");
+
+    // --- Arithmetic / literals ---
+    // DVX BASIC PRINT format: "N \n" for positive/zero integers, "-N \n"
+    // for negatives.  No leading space (unlike classic QBASIC).
+    TEST_EQ("int-add",          "PRINT 2 + 3\n", "5 \n");
+    TEST_EQ("int-sub",          "PRINT 10 - 4\n", "6 \n");
+    TEST_EQ("int-mul",          "PRINT 6 * 7\n", "42 \n");
+    TEST_EQ("int-div-float",    "PRINT 10 / 4\n", "2.5 \n");
+    TEST_EQ("int-idiv",         "PRINT 10 \\ 3\n", "3 \n");
+    TEST_EQ("int-mod",          "PRINT 17 MOD 5\n", "2 \n");
+    TEST_EQ("int-pow",          "PRINT 2 ^ 10\n", "1024 \n");
+    TEST_EQ("precedence",       "PRINT 2 + 3 * 4\n", "14 \n");
+    TEST_EQ("parens",           "PRINT (2 + 3) * 4\n", "20 \n");
+    TEST_EQ("unary-neg",        "PRINT -5 + 2\n", "-3 \n");
+    TEST_EQ("double-lit",       "PRINT 1.5 + 2.25\n", "3.75 \n");
+
+    // --- Strings ---
+    TEST_EQ("string-concat",    "PRINT \"foo\" + \"bar\"\n", "foobar\n");
+    TEST_EQ("string-len",       "PRINT LEN(\"hello\")\n", "5 \n");
+    TEST_EQ("string-left",      "PRINT LEFT$(\"abcdef\", 3)\n", "abc\n");
+    TEST_EQ("string-right",     "PRINT RIGHT$(\"abcdef\", 2)\n", "ef\n");
+    TEST_EQ("string-mid",       "PRINT MID$(\"abcdef\", 2, 3)\n", "bcd\n");
+    TEST_EQ("string-ucase",     "PRINT UCASE$(\"Hello\")\n", "HELLO\n");
+    TEST_EQ("string-lcase",     "PRINT LCASE$(\"Hello\")\n", "hello\n");
+    TEST_EQ("string-str",       "PRINT STR$(42)\n", " 42\n");
+    TEST_EQ("string-val",       "PRINT VAL(\"123\")\n", "123 \n");
+
+    // --- Control flow ---
+    TEST_EQ("if-then",
+        "DIM x AS INTEGER\nx = 5\nIF x > 3 THEN PRINT \"big\"\n",
+        "big\n");
+    TEST_EQ("if-else",
+        "DIM x AS INTEGER\nx = 1\nIF x > 3 THEN PRINT \"big\" ELSE PRINT \"small\"\n",
+        "small\n");
+    TEST_EQ("if-block",
+        "DIM x AS INTEGER\nx = 5\nIF x > 3 THEN\n  PRINT \"a\"\n  PRINT \"b\"\nEND IF\n",
+        "a\nb\n");
+    TEST_EQ("select-case",
+        "DIM n AS INTEGER\nn = 2\n"
+        "SELECT CASE n\n"
+        "  CASE 1: PRINT \"one\"\n"
+        "  CASE 2: PRINT \"two\"\n"
+        "  CASE ELSE: PRINT \"other\"\n"
+        "END SELECT\n",
+        "two\n");
+
+    // --- FOR loops (the recent bug: double step was truncating) ---
+    TEST_EQ("for-int-step",
+        "DIM i AS INTEGER\nFOR i = 1 TO 3\n  PRINT i\nNEXT i\n",
+        "1 \n2 \n3 \n");
+    TEST_EQ("for-neg-step",
+        "DIM i AS INTEGER\nFOR i = 3 TO 1 STEP -1\n  PRINT i\nNEXT i\n",
+        "3 \n2 \n1 \n");
+    TEST_EQ("for-double-step",
+        // Regression: DIM AS DOUBLE with fractional STEP must not
+        // truncate the increment to zero.  Pre-fix this looped forever.
+        "DIM a AS DOUBLE\n"
+        "DIM c AS INTEGER\n"
+        "c = 0\n"
+        "FOR a = 0 TO 1 STEP 0.25\n"
+        "  c = c + 1\n"
+        "NEXT a\n"
+        "PRINT c\n",
+        "5 \n");
+    TEST_EQ("for-double-six-cycles",
+        // Direct analogue of GfxDrawAll's circle loop.
+        "DIM a AS DOUBLE\n"
+        "DIM c AS INTEGER\n"
+        "c = 0\n"
+        "FOR a = 0 TO 6.3 STEP 0.2\n"
+        "  c = c + 1\n"
+        "NEXT a\n"
+        "PRINT c\n",
+        "32 \n");
+    TEST_EQ("for-exit",
+        "DIM i AS INTEGER\n"
+        "FOR i = 1 TO 10\n"
+        "  IF i = 4 THEN EXIT FOR\n"
+        "  PRINT i\n"
+        "NEXT i\n",
+        "1 \n2 \n3 \n");
+
+    // --- WHILE / DO loops ---
+    TEST_EQ("do-while",
+        "DIM i AS INTEGER\n"
+        "i = 0\n"
+        "DO WHILE i < 3\n"
+        "  PRINT i\n"
+        "  i = i + 1\n"
+        "LOOP\n",
+        "0 \n1 \n2 \n");
+    TEST_EQ("do-until",
+        "DIM i AS INTEGER\n"
+        "i = 0\n"
+        "DO\n"
+        "  i = i + 1\n"
+        "LOOP UNTIL i = 3\n"
+        "PRINT i\n",
+        "3 \n");
+
+    // --- SUB / FUNCTION ---
+    TEST_EQ("sub-call",
+        "SUB greet\n  PRINT \"hi\"\nEND SUB\n"
+        "greet\n",
+        "hi\n");
+    TEST_EQ("sub-params",
+        "SUB twice(n AS INTEGER)\n  PRINT n * 2\nEND SUB\n"
+        "twice 7\n",
+        "14 \n");
+    TEST_EQ("function-return",
+        "FUNCTION sq(n AS INTEGER) AS INTEGER\n  sq = n * n\nEND FUNCTION\n"
+        "PRINT sq(9)\n",
+        "81 \n");
+    TEST_EQ("recursion",
+        "FUNCTION fact(n AS INTEGER) AS LONG\n"
+        "  IF n <= 1 THEN\n    fact = 1\n  ELSE\n    fact = n * fact(n - 1)\n  END IF\n"
+        "END FUNCTION\n"
+        "PRINT fact(6)\n",
+        "720 \n");
+    TEST_EQ("byref-vs-byval",
+        "SUB bump(BYVAL a AS INTEGER, b AS INTEGER)\n  a = a + 1\n  b = b + 1\nEND SUB\n"
+        "DIM x AS INTEGER\nDIM y AS INTEGER\n"
+        "x = 10\ny = 20\n"
+        "bump x, y\n"
+        "PRINT x\nPRINT y\n",
+        "10 \n21 \n");
+
+    // --- Globals from within SUB ---
+    TEST_EQ("global-from-sub",
+        // Regression: the basdemo Dynamic Form bug was caused by
+        // SUBs unable to access module-level DIM.
+        "DIM counter AS INTEGER\n"
+        "counter = 0\n"
+        "SUB inc\n  counter = counter + 1\nEND SUB\n"
+        "inc\ninc\ninc\n"
+        "PRINT counter\n",
+        "3 \n");
+
+    // --- BEGINFORM / ENDFORM ---
+    // Inside BEGINFORM, DIMs become form-scope vars; SUBs still live
+    // globally but inherit the owning form context when called through
+    // basVmCallSub with form vars bound.  Running the init code requires
+    // a form to actually be loaded (formrt sets currentFormVars) so we
+    // can only verify the parser accepts/rejects forms here.
+    TEST_COMPILE_ERROR("nested-beginform",
+        "BEGINFORM \"A\"\nBEGINFORM \"B\"\nENDFORM\nENDFORM\n",
+        "Nested BEGINFORM");
+    TEST_COMPILE_ERROR("beginform-in-sub",
+        "SUB foo\nBEGINFORM \"X\"\nENDFORM\nEND SUB\n",
+        "BEGINFORM inside SUB");
+
+    // --- Arrays ---
+    TEST_EQ("array-1d",
+        "DIM a(3) AS INTEGER\n"
+        "a(0) = 10\na(1) = 20\na(2) = 30\na(3) = 40\n"
+        "PRINT a(0) + a(3)\n",
+        "50 \n");
+    TEST_EQ("array-lbound-ubound",
+        "DIM a(5 TO 9) AS INTEGER\n"
+        "PRINT LBOUND(a)\nPRINT UBOUND(a)\n",
+        "5 \n9 \n");
+
+    // --- UDT ---
+    TEST_EQ("udt-fields",
+        "TYPE Pt\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
+        "DIM p AS Pt\n"
+        "p.x = 3\np.y = 4\n"
+        "PRINT p.x + p.y\n",
+        "7 \n");
+
+    // --- ON ERROR GOTO ---
+    TEST_EQ("on-error-module",
+        "ON ERROR GOTO handler\n"
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 10\nb = 0\n"
+        "PRINT a \\ b\n"
+        "PRINT \"never\"\n"
+        "END\n"
+        "handler:\n"
+        "PRINT \"caught\"\n"
+        "PRINT ERR\n",
+        "caught\n11 \n");
+    TEST_EQ("on-error-in-sub",
+        // Regression: .frm's btnError_Click demo has ON ERROR inside a
+        // SUB.  Make sure the handler label resolves within the SUB.
+        "SUB doit\n"
+        "  ON ERROR GOTO handler\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  PRINT a \\ b\n"
+        "  PRINT \"never\"\n"
+        "  EXIT SUB\n"
+        "  handler:\n"
+        "  PRINT \"caught\"\n"
+        "  PRINT ERR\n"
+        "END SUB\n"
+        "doit\n",
+        "caught\n11 \n");
+
+    // --- Runtime errors surface ---
+    TEST_RUNTIME_ERROR("divzero-no-handler",
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 10\nb = 0\n"
+        "PRINT a \\ b\n",
+        "");
+
+    // --- Compile-time checks ---
+    TEST_COMPILE_ERROR("unknown-keyword",
+        "FLOOGLE 1, 2, 3\n",
+        "");
+
+    // --- STATIC variables in SUB persist across calls ---
+    TEST_EQ("static-local",
+        "SUB inc\n"
+        "  STATIC n AS INTEGER\n"
+        "  n = n + 1\n"
+        "  PRINT n\n"
+        "END SUB\n"
+        "inc\ninc\ninc\n",
+        "1 \n2 \n3 \n");
+
+    // --- GOSUB / RETURN ---
+    TEST_EQ("gosub-return",
+        "GOSUB sub1\n"
+        "PRINT \"back\"\n"
+        "END\n"
+        "sub1:\n"
+        "PRINT \"in sub\"\n"
+        "RETURN\n",
+        "in sub\nback\n");
+
+    // --- SWAP ---
+    TEST_EQ("swap",
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 1\nb = 9\n"
+        "SWAP a, b\n"
+        "PRINT a\nPRINT b\n",
+        "9 \n1 \n");
+
+    // --- DATA / READ ---
+    TEST_EQ("data-read",
+        "DIM x AS INTEGER\n"
+        "READ x\nPRINT x\n"
+        "READ x\nPRINT x\n"
+        "DATA 11, 22\n",
+        "11 \n22 \n");
+
+    // --- FORMAT$ ---
+    TEST_EQ("format-number",
+        "PRINT FORMAT$(1234.5, \"#,##0.00\")\n",
+        "1,234.50\n");
+
+    // --- String comparison ---
+    TEST_EQ("string-equal",
+        "IF \"abc\" = \"abc\" THEN PRINT \"y\" ELSE PRINT \"n\"\n",
+        "y\n");
+    TEST_EQ("string-less",
+        "IF \"abc\" < \"abd\" THEN PRINT \"y\" ELSE PRINT \"n\"\n",
+        "y\n");
+
+    // --- OPTION EXPLICIT enforces DIM ---
+    TEST_COMPILE_ERROR("option-explicit",
+        "OPTION EXPLICIT\n"
+        "x = 5\n",
+        "");
+
+    // --- Math functions ---
+    TEST_EQ("math-abs",     "PRINT ABS(-7)\n", "7 \n");
+    TEST_EQ("math-int",     "PRINT INT(3.7)\n", "3 \n");
+    TEST_EQ("math-sgn-pos", "PRINT SGN(5)\n", "1 \n");
+    TEST_EQ("math-sgn-neg", "PRINT SGN(-5)\n", "-1 \n");
+    TEST_EQ("math-sgn-zero","PRINT SGN(0)\n", "0 \n");
+
+    // --- CAST/conversion ---
+    TEST_EQ("cint",   "PRINT CINT(3.6)\n", "4 \n");
+    TEST_EQ("clng",   "PRINT CLNG(100000)\n", "100000 \n");
+    TEST_EQ("cdbl",   "PRINT CDBL(1) / 3\n", "0.333333 \n");
+
+    // --- Float arithmetic preserves fractional results ---
+    // Regression: the VM was promoting OP_ADD_INT results back to int16
+    // even when either operand was a float, truncating 1.5+2.25 to 3.
+    TEST_EQ("float-add-literal",  "PRINT 1.5 + 2.25\n", "3.75 \n");
+    TEST_EQ("float-add-var",
+        "DIM a AS DOUBLE\nDIM b AS DOUBLE\n"
+        "a = 1.5\nb = 2.25\n"
+        "PRINT a + b\n",
+        "3.75 \n");
+    TEST_EQ("float-mul",
+        "PRINT 2.5 * 4\n",
+        "10 \n");
+    TEST_EQ("float-sub",
+        "PRINT 10 - 0.5\n",
+        "9.5 \n");
+    TEST_EQ("div-produces-double",
+        "PRINT 7 / 2\n",
+        "3.5 \n");
+
+    // --- ON ERROR with float division (the actual .frm demo scenario) ---
+    TEST_EQ("on-error-float-div",
+        "SUB doit\n"
+        "  ON ERROR GOTO handler\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  PRINT a / b\n"
+        "  PRINT \"never\"\n"
+        "  EXIT SUB\n"
+        "  handler:\n"
+        "  PRINT \"caught\"\n"
+        "  PRINT ERR\n"
+        "END SUB\n"
+        "doit\n",
+        "caught\n11 \n");
+
+    // --- ON ERROR, error during arg evaluation of a nested SUB call ---
+    // Mirrors basdemo's btnError_Click: Say "..." + STR$(a / b) causes the
+    // divide-by-zero inside an expression that's about to be an argument
+    // to Say.  The error handler for the outer SUB must still catch it.
+    TEST_EQ("on-error-in-expr-arg",
+        "SUB emit(s AS STRING)\n  PRINT s\nEND SUB\n"
+        "SUB doit\n"
+        "  ON ERROR GOTO handler\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  emit \"10/\" + STR$(b) + \"=\" + STR$(a / b)\n"
+        "  PRINT \"never\"\n"
+        "  EXIT SUB\n"
+        "  handler:\n"
+        "  PRINT \"caught\"\n"
+        "  PRINT ERR\n"
+        "END SUB\n"
+        "doit\n",
+        "caught\n11 \n");
+
+    // --- SUB is called from another SUB; error fires in the callee ---
+    // When one SUB calls another and the *callee* has no handler but the
+    // *caller* does, the caller's handler should still catch the error.
+    TEST_EQ("on-error-bubbles-up",
+        "SUB bomb\n  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n  PRINT a / b\n"
+        "END SUB\n"
+        "SUB doit\n"
+        "  ON ERROR GOTO handler\n"
+        "  bomb\n"
+        "  PRINT \"never\"\n"
+        "  EXIT SUB\n"
+        "  handler:\n"
+        "  PRINT \"caught in doit\"\n"
+        "  PRINT ERR\n"
+        "END SUB\n"
+        "doit\n",
+        "caught in doit\n11 \n");
+
+    // --- Handler calls other SUBs, then completes normally ---
+    // Mirrors basdemo's btnError_Click: the handler itself calls `Say`
+    // and sets properties before END SUB.  After the handler ends, the
+    // outer SUB's caller (Run All chain) must continue normally.
+    TEST_EQ("on-error-handler-calls-sub",
+        "SUB emit(s AS STRING)\n  PRINT s\nEND SUB\n"
+        "SUB doit\n"
+        "  ON ERROR GOTO handler\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  emit \"about to divide\"\n"
+        "  emit STR$(a / b)\n"
+        "  emit \"never\"\n"
+        "  EXIT SUB\n"
+        "  handler:\n"
+        "  emit \"caught\"\n"
+        "  emit STR$(ERR)\n"
+        "END SUB\n"
+        "doit\n"
+        "PRINT \"after-doit\"\n",
+        "about to divide\ncaught\n 11\nafter-doit\n");
+
+    // --- Run-All style chain: caller runs several SUBs in sequence,
+    // one of them has a handler that catches its own error, the chain
+    // must continue with the next SUB in the caller. ---
+    TEST_EQ("on-error-chain-continues",
+        "SUB stepOne\n  PRINT \"one\"\nEND SUB\n"
+        "SUB stepBoom\n"
+        "  ON ERROR GOTO h\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  PRINT a \\ b\n"
+        "  PRINT \"never\"\n"
+        "  EXIT SUB\n"
+        "  h:\n"
+        "  PRINT \"boom-caught\"\n"
+        "END SUB\n"
+        "SUB stepTwo\n  PRINT \"two\"\nEND SUB\n"
+        "SUB runAll\n"
+        "  stepOne\n"
+        "  stepBoom\n"
+        "  stepTwo\n"
+        "END SUB\n"
+        "runAll\n",
+        "one\nboom-caught\ntwo\n");
+
+    // --- Multiple ON ERROR blocks in sequence in one SUB ---
+    TEST_EQ("on-error-reset",
+        "SUB doit\n"
+        "  ON ERROR GOTO h1\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  PRINT a \\ b\n"
+        "  EXIT SUB\n"
+        "  h1:\n"
+        "  PRINT \"h1\"\n"
+        "END SUB\n"
+        "doit\n"
+        "PRINT \"done\"\n",
+        "h1\ndone\n");
+
+    // --- ON ERROR GOTO 0 clears the handler ---
+    TEST_RUNTIME_ERROR("on-error-goto-zero",
+        "ON ERROR GOTO handler\n"
+        "ON ERROR GOTO 0\n"
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 10\nb = 0\n"
+        "PRINT a \\ b\n"
+        "END\n"
+        "handler:\n"
+        "PRINT \"not caught\"\n",
+        "");
+
+    // --- inErrorHandler must clear after handler completes via END SUB ---
+    // If we don't clear it, a *second* error in a later SUB wouldn't trap.
+    TEST_EQ("on-error-reusable",
+        "SUB doit\n"
+        "  ON ERROR GOTO h\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  PRINT a \\ b\n"
+        "  EXIT SUB\n"
+        "  h:\n"
+        "  PRINT \"once\"\n"
+        "END SUB\n"
+        "doit\n"
+        "doit\n",
+        "once\nonce\n");
+
+    // --- String builtins ---
+    TEST_EQ("string-instr",  "PRINT INSTR(\"abcdef\", \"cd\")\n", "3 \n");
+    TEST_EQ("string-chr",    "PRINT CHR$(65)\n", "A\n");
+    TEST_EQ("string-asc",    "PRINT ASC(\"A\")\n", "65 \n");
+    TEST_EQ("string-space",  "PRINT \"[\" + SPACE$(3) + \"]\"\n", "[   ]\n");
+    TEST_EQ("string-string", "PRINT STRING$(4, \"*\")\n", "****\n");
+    TEST_EQ("string-ltrim",  "PRINT \"[\" + LTRIM$(\"   hi\") + \"]\"\n", "[hi]\n");
+    TEST_EQ("string-rtrim",  "PRINT \"[\" + RTRIM$(\"hi   \") + \"]\"\n", "[hi]\n");
+
+    // --- Logical operators ---
+    TEST_EQ("logical-and",
+        "IF 1 = 1 AND 2 = 2 THEN PRINT \"y\" ELSE PRINT \"n\"\n",
+        "y\n");
+    TEST_EQ("logical-or",
+        "IF 1 = 0 OR 2 = 2 THEN PRINT \"y\" ELSE PRINT \"n\"\n",
+        "y\n");
+    TEST_EQ("logical-not",
+        "IF NOT (1 = 2) THEN PRINT \"y\" ELSE PRINT \"n\"\n",
+        "y\n");
+
+    // --- Short-circuit only fires when needed (documented behavior) ---
+    TEST_EQ("and-short-circuit-full",
+        "DIM x AS INTEGER\nx = 0\n"
+        "IF 1 = 1 AND 2 = 2 THEN x = 5\n"
+        "PRINT x\n",
+        "5 \n");
+
+    // --- Nested FOR ---
+    TEST_EQ("nested-for",
+        "DIM i AS INTEGER\nDIM j AS INTEGER\n"
+        "FOR i = 1 TO 2\n"
+        "  FOR j = 1 TO 2\n"
+        "    PRINT i * 10 + j\n"
+        "  NEXT j\n"
+        "NEXT i\n",
+        "11 \n12 \n21 \n22 \n");
+
+    // --- WHILE / WEND (older syntax) ---
+    TEST_EQ("while-wend",
+        "DIM i AS INTEGER\ni = 0\n"
+        "WHILE i < 3\n"
+        "  PRINT i\n  i = i + 1\n"
+        "WEND\n",
+        "0 \n1 \n2 \n");
+
+    // --- 2D array ---
+    TEST_EQ("array-2d",
+        "DIM a(2, 2) AS INTEGER\n"
+        "a(0, 0) = 1\na(1, 1) = 5\na(2, 2) = 9\n"
+        "PRINT a(0, 0) + a(1, 1) + a(2, 2)\n",
+        "15 \n");
+
+    // --- REDIM PRESERVE keeps old values ---
+    TEST_EQ("redim-preserve",
+        "DIM a(2) AS INTEGER\n"
+        "a(0) = 10\na(1) = 20\na(2) = 30\n"
+        "REDIM PRESERVE a(4)\n"
+        "a(3) = 40\na(4) = 50\n"
+        "PRINT a(0) + a(2) + a(4)\n",
+        "90 \n");
+
+    // --- Nested UDT ---
+    TEST_EQ("udt-nested",
+        "TYPE Inner\n  x AS INTEGER\nEND TYPE\n"
+        "TYPE Outer\n  lbl AS STRING\n  pt AS Inner\nEND TYPE\n"
+        "DIM o AS Outer\n"
+        "o.lbl = \"hi\"\n"
+        "o.pt.x = 42\n"
+        "PRINT o.lbl\nPRINT o.pt.x\n",
+        "hi\n42 \n");
+
+    // --- Multi-value PRINT with comma/semicolon ---
+    TEST_EQ("print-semi",
+        "PRINT \"a\"; \"b\"; \"c\"\n",
+        "abc\n");
+
+    // --- EXIT SUB ---
+    TEST_EQ("exit-sub-early",
+        "SUB foo(n AS INTEGER)\n"
+        "  IF n < 0 THEN EXIT SUB\n"
+        "  PRINT n\n"
+        "END SUB\n"
+        "foo 5\nfoo -1\nfoo 9\n",
+        "5 \n9 \n");
+
+    // --- EXIT FUNCTION ---
+    TEST_EQ("exit-function-early",
+        "FUNCTION absval(n AS INTEGER) AS INTEGER\n"
+        "  IF n < 0 THEN\n    absval = -n\n    EXIT FUNCTION\n  END IF\n"
+        "  absval = n\n"
+        "END FUNCTION\n"
+        "PRINT absval(-7)\nPRINT absval(3)\n",
+        "7 \n3 \n");
+
+    // --- Module globals initialize ---
+    TEST_EQ("global-init-order",
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 10\nb = a * 2\n"
+        "PRINT b\n",
+        "20 \n");
+
+    // --- STEP 0 or near-zero should not infinite-loop (if STEP > 0, needs > 0) ---
+    TEST_EQ("for-empty-range",
+        "DIM i AS INTEGER\n"
+        "FOR i = 10 TO 5\n  PRINT i\nNEXT i\n"
+        "PRINT \"done\"\n",
+        "done\n");
+
+    // --- CONST values ---
+    TEST_EQ("const",
+        "CONST PI = 3\n"
+        "PRINT PI * 2\n",
+        "6 \n");
+
+    // --- Nested SUB calls preserve locals ---
+    TEST_EQ("nested-sub-locals",
+        "SUB inner(x AS INTEGER)\n  PRINT x * 10\nEND SUB\n"
+        "SUB outer(y AS INTEGER)\n"
+        "  inner y\n"
+        "  PRINT y\n"
+        "END SUB\n"
+        "outer 7\n",
+        "70 \n7 \n");
+
+    // --- Recursive sum ---
+    TEST_EQ("recursion-sum",
+        "FUNCTION sumto(n AS INTEGER) AS LONG\n"
+        "  IF n <= 0 THEN\n    sumto = 0\n  ELSE\n    sumto = n + sumto(n - 1)\n  END IF\n"
+        "END FUNCTION\n"
+        "PRINT sumto(10)\n",
+        "55 \n");
+
+    // --- Early EXIT DO ---
+    TEST_EQ("exit-do",
+        "DIM i AS INTEGER\ni = 0\n"
+        "DO\n"
+        "  IF i = 3 THEN EXIT DO\n"
+        "  PRINT i\n  i = i + 1\n"
+        "LOOP\n",
+        "0 \n1 \n2 \n");
+
+    // --- String concatenation with numbers via STR$ ---
+    TEST_EQ("string-concat-num",
+        "DIM n AS INTEGER\n"
+        "n = 42\n"
+        "PRINT \"n=\" + STR$(n)\n",
+        "n= 42\n");
+
+    // --- VAL of empty string returns 0 ---
+    TEST_EQ("val-empty",
+        "PRINT VAL(\"\")\n",
+        "0 \n");
+
+    // --- VAL parses up to first non-numeric ---
+    TEST_EQ("val-parse-prefix",
+        "PRINT VAL(\"42abc\")\n",
+        "42 \n");
+
+    // --- LEFT$/RIGHT$/MID$ with length past end ---
+    TEST_EQ("string-bounds-safe",
+        "PRINT LEFT$(\"hi\", 10)\n"
+        "PRINT RIGHT$(\"hi\", 10)\n"
+        "PRINT MID$(\"hi\", 1, 10)\n",
+        "hi\nhi\nhi\n");
+
+    // --- INSTR with no match returns 0 ---
+    TEST_EQ("instr-nomatch",
+        "PRINT INSTR(\"abc\", \"z\")\n",
+        "0 \n");
+
+    // --- IF with ELSEIF chain ---
+    TEST_EQ("elseif-chain",
+        "DIM n AS INTEGER\nn = 2\n"
+        "IF n = 1 THEN\n  PRINT \"one\"\n"
+        "ELSEIF n = 2 THEN\n  PRINT \"two\"\n"
+        "ELSEIF n = 3 THEN\n  PRINT \"three\"\n"
+        "ELSE\n  PRINT \"other\"\nEND IF\n",
+        "two\n");
+
+    // --- FOR with STEP > range size skips body ---
+    TEST_EQ("for-step-over",
+        "DIM i AS INTEGER\n"
+        "FOR i = 1 TO 5 STEP 10\n  PRINT i\nNEXT i\n",
+        "1 \n");
+
+    // --- SELECT CASE with range ---
+    TEST_EQ("select-case-range",
+        "DIM n AS INTEGER\nn = 5\n"
+        "SELECT CASE n\n"
+        "  CASE 1 TO 3: PRINT \"low\"\n"
+        "  CASE 4 TO 6: PRINT \"mid\"\n"
+        "  CASE ELSE: PRINT \"high\"\n"
+        "END SELECT\n",
+        "mid\n");
+
+    // --- SELECT CASE with IS comparator ---
+    TEST_EQ("select-case-is",
+        "DIM n AS INTEGER\nn = 100\n"
+        "SELECT CASE n\n"
+        "  CASE IS < 10: PRINT \"small\"\n"
+        "  CASE IS >= 10: PRINT \"big\"\n"
+        "END SELECT\n",
+        "big\n");
+
+    // --- SUB forward reference backpatched ---
+    TEST_EQ("forward-sub-ref",
+        "later\n"
+        "SUB later\n  PRINT \"ok\"\nEND SUB\n",
+        "ok\n");
+
+    // --- FUNCTION without explicit return returns default value ---
+    TEST_EQ("function-default-return",
+        "FUNCTION zero AS INTEGER\nEND FUNCTION\n"
+        "PRINT zero\n",
+        "0 \n");
+
+    // --- STATIC counter across SUB invocations with params ---
+    TEST_EQ("static-with-params",
+        "SUB inc(n AS INTEGER)\n"
+        "  STATIC total AS INTEGER\n"
+        "  total = total + n\n"
+        "  PRINT total\n"
+        "END SUB\n"
+        "inc 5\ninc 10\ninc 15\n",
+        "5 \n15 \n30 \n");
+
+    // --- IIF equivalent via IF/THEN/ELSE expression on same line ---
+    TEST_EQ("inline-if-expr",
+        "DIM n AS INTEGER\nn = 7\n"
+        "IF n > 5 THEN PRINT \"big\" ELSE PRINT \"small\"\n",
+        "big\n");
+
+    // --- CONST string ---
+    TEST_EQ("const-string",
+        "CONST GREETING = \"hello\"\n"
+        "PRINT GREETING\n",
+        "hello\n");
+
+    // --- RND and RANDOMIZE don't crash ---
+    TEST_EQ("rnd-fires",
+        "RANDOMIZE 42\n"
+        "DIM n AS DOUBLE\n"
+        "n = RND\n"
+        "IF n >= 0 AND n < 1 THEN PRINT \"ok\"\n",
+        "ok\n");
+
+    // --- TIMER returns a number (just verify it runs) ---
+    TEST_EQ("timer-fires",
+        "DIM t AS DOUBLE\nt = TIMER\n"
+        "IF t >= 0 THEN PRINT \"ok\"\n",
+        "ok\n");
+
+    // --- Integer overflow promotes to LONG ---
+    TEST_EQ("int-to-long-promotion",
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 20000\nb = 20000\n"
+        "PRINT a + b\n",
+        "40000 \n");
+
+    // --- String compare case sensitivity ---
+    TEST_EQ("string-case-compare",
+        "IF \"Abc\" = \"abc\" THEN PRINT \"eq\" ELSE PRINT \"ne\"\n",
+        "ne\n");
+
+    // --- OPTION COMPARE TEXT if supported; otherwise skip ---
+    // Omitted: checking support first would just cause a compile failure.
+
+    // --- Unary NOT on ints (bitwise) ---
+    TEST_EQ("bitwise-not",
+        "PRINT NOT 0\n",
+        "-1 \n");
+
+    // --- AND/OR as bitwise on ints ---
+    TEST_EQ("bitwise-and",
+        "PRINT 12 AND 10\n",
+        "8 \n");
+    TEST_EQ("bitwise-or",
+        "PRINT 12 OR 10\n",
+        "14 \n");
+
+    // --- STR$ with negative ---
+    TEST_EQ("str-negative",
+        "PRINT \"[\" + STR$(-5) + \"]\"\n",
+        "[-5]\n");
+
+    // --- INT vs FIX on negative ---
+    TEST_EQ("int-negative",
+        "PRINT INT(-1.5)\n",
+        "-2 \n");
+    TEST_EQ("fix-negative",
+        "PRINT FIX(-1.5)\n",
+        "-1 \n");
+
+    // --- MOD on negatives ---
+    TEST_EQ("mod-negative",
+        "PRINT -7 MOD 3\n",
+        "-1 \n");
+
+    // --- SQR ---
+    TEST_EQ("sqr",
+        "PRINT SQR(16)\n",
+        "4 \n");
+
+    // --- LEN of empty ---
+    TEST_EQ("len-empty",
+        "PRINT LEN(\"\")\n",
+        "0 \n");
+
+    // --- Array of UDTs ---
+    TEST_EQ("array-of-udt",
+        "TYPE P\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
+        "DIM arr(1) AS P\n"
+        "arr(0).x = 1\narr(0).y = 2\n"
+        "arr(1).x = 10\narr(1).y = 20\n"
+        "PRINT arr(0).x + arr(1).y\n",
+        "21 \n");
+
+    // --- Empty FOR range, explicit STEP 1 ---
+    TEST_EQ("for-step1-empty",
+        "DIM i AS INTEGER\n"
+        "FOR i = 5 TO 1 STEP 1\n  PRINT i\nNEXT i\n"
+        "PRINT \"done\"\n",
+        "done\n");
+
+    // --- Negative-step range where start = end ---
+    TEST_EQ("for-single-iter",
+        "DIM i AS INTEGER\n"
+        "FOR i = 3 TO 3 STEP -1\n  PRINT i\nNEXT i\n",
+        "3 \n");
+
+    // ============================================================
+    // Event-handler dispatch path (runSubLoop, not basVmRun)
+    // ============================================================
+    // fireCtrlEvent calls basVmCallSub for each event handler.  That
+    // routes through runSubLoop which has its own error-dispatch loop.
+    // The tests above exercise basVmRun only; these exercise the SUB
+    // call path that real event handlers use.
+
+    TEST_SUB_EQ("sub-call-basic",
+        "SUB handler\n  PRINT \"hi\"\nEND SUB\n",
+        "handler",
+        "hi\n");
+
+    TEST_SUB_EQ("sub-call-on-error-div",
+        "SUB handler\n"
+        "  ON ERROR GOTO h\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  PRINT a \\ b\n"
+        "  PRINT \"never\"\n"
+        "  EXIT SUB\n"
+        "  h:\n"
+        "  PRINT \"caught\"\n"
+        "  PRINT ERR\n"
+        "END SUB\n",
+        "handler",
+        "caught\n11 \n");
+
+    TEST_SUB_EQ("sub-call-on-error-float-div",
+        "SUB handler\n"
+        "  ON ERROR GOTO h\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  PRINT a / b\n"
+        "  PRINT \"never\"\n"
+        "  EXIT SUB\n"
+        "  h:\n"
+        "  PRINT \"caught\"\n"
+        "  PRINT ERR\n"
+        "END SUB\n",
+        "handler",
+        "caught\n11 \n");
+
+    // Regression: basdemo's Run All calls btnError_Click via OP_CALL.
+    // The error dispatcher must work when the SUB-under-dispatch runs
+    // nested calls before hitting the error.
+    TEST_SUB_EQ("sub-call-on-error-after-nested-calls",
+        "SUB greet(s AS STRING)\n  PRINT s\nEND SUB\n"
+        "SUB handler\n"
+        "  greet \"before\"\n"
+        "  ON ERROR GOTO h\n"
+        "  greet \"armed\"\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  greet STR$(a / b)\n"
+        "  greet \"never\"\n"
+        "  EXIT SUB\n"
+        "  h:\n"
+        "  greet \"caught\"\n"
+        "END SUB\n",
+        "handler",
+        "before\narmed\ncaught\n");
+
+    // Regression: Run All dispatches many SUBs in sequence via OP_CALL.
+    // One of them has a handler that catches its own error; the chain
+    // must continue with the next SUB AND a later SUB's error must
+    // still be trappable (inErrorHandler must reset).
+    TEST_SUB_EQ("sub-call-run-all-chain",
+        "SUB stepOne\n  PRINT \"1\"\nEND SUB\n"
+        "SUB stepBoom\n"
+        "  ON ERROR GOTO h\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 10\n  b = 0\n"
+        "  PRINT a \\ b\n"
+        "  EXIT SUB\n"
+        "  h:\n"
+        "  PRINT \"b-caught\"\n"
+        "END SUB\n"
+        "SUB stepTwo\n  PRINT \"2\"\nEND SUB\n"
+        "SUB stepBoom2\n"
+        "  ON ERROR GOTO h2\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 20\n  b = 0\n"
+        "  PRINT a \\ b\n"
+        "  EXIT SUB\n"
+        "  h2:\n"
+        "  PRINT \"b2-caught\"\n"
+        "END SUB\n"
+        "SUB runAll\n"
+        "  stepOne\n"
+        "  stepBoom\n"
+        "  stepTwo\n"
+        "  stepBoom2\n"
+        "  PRINT \"done\"\n"
+        "END SUB\n",
+        "runAll",
+        "1\nb-caught\n2\nb2-caught\ndone\n");
+
+    // ============================================================
+    // Numeric limits and overflow behavior
+    // ============================================================
+
+    TEST_EQ("int-max",          "PRINT 32767\n", "32767 \n");
+    TEST_EQ("int-min",          "PRINT -32768\n", "-32768 \n");
+    TEST_EQ("int-overflow-promotes",
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 32000\nb = 32000\n"
+        "PRINT a + b\n",
+        "64000 \n");
+    TEST_EQ("long-max",         "PRINT 2147483647\n", "2147483647 \n");
+    TEST_EQ("long-min",         "PRINT -2147483648\n", "-2147483648 \n");
+
+    // ============================================================
+    // Float formatting and precision
+    // ============================================================
+
+    TEST_EQ("float-zero",       "PRINT 0.0\n", "0 \n");
+    TEST_EQ("float-one",        "PRINT 1.0\n", "1 \n");
+    TEST_EQ("float-tiny",       "PRINT 0.001\n", "0.001 \n");
+    TEST_EQ("float-neg",        "PRINT -3.14\n", "-3.14 \n");
+    // DVX uses %g formatting (~6 digits of precision) for doubles.
+    TEST_EQ("float-via-div",    "PRINT 22 / 7\n", "3.14286 \n");
+    TEST_EQ("float-large",      "PRINT 1000000.5\n", "1e+06 \n");
+
+    // ============================================================
+    // Integer division and MOD edge cases
+    // ============================================================
+
+    TEST_EQ("idiv-negative-positive", "PRINT -7 \\ 2\n", "-3 \n");
+    TEST_EQ("idiv-positive-negative", "PRINT 7 \\ -2\n", "-3 \n");
+    TEST_EQ("mod-positive",           "PRINT 7 MOD 3\n", "1 \n");
+    TEST_EQ("mod-zero-dividend",      "PRINT 0 MOD 3\n", "0 \n");
+
+    TEST_RUNTIME_ERROR("idiv-zero",    "PRINT 5 \\ 0\n", "");
+    TEST_RUNTIME_ERROR("fdiv-zero",    "PRINT 5 / 0\n", "");
+    TEST_RUNTIME_ERROR("mod-zero",     "PRINT 5 MOD 0\n", "");
+
+    // ============================================================
+    // Comparison operators
+    // ============================================================
+
+    // DVX BASIC prints boolean-typed values as "True"/"False" rather
+    // than -1/0.  (Same value, different PRINT formatting.)
+    TEST_EQ("cmp-eq-true",      "PRINT (5 = 5)\n", "True \n");
+    TEST_EQ("cmp-eq-false",     "PRINT (5 = 6)\n", "False \n");
+    TEST_EQ("cmp-ne",           "PRINT (5 <> 6)\n", "True \n");
+    TEST_EQ("cmp-lt",           "PRINT (3 < 5)\n", "True \n");
+    TEST_EQ("cmp-le",           "PRINT (5 <= 5)\n", "True \n");
+    TEST_EQ("cmp-gt",           "PRINT (7 > 5)\n", "True \n");
+    TEST_EQ("cmp-ge",           "PRINT (5 >= 5)\n", "True \n");
+    TEST_EQ("cmp-mixed-types",  "PRINT (5 = 5.0)\n", "True \n");
+
+    // ============================================================
+    // Boolean operators
+    // ============================================================
+
+    TEST_EQ("bool-true-const",  "PRINT TRUE\n", "True \n");
+    TEST_EQ("bool-false-const", "PRINT FALSE\n", "False \n");
+    TEST_EQ("xor",              "PRINT 12 XOR 10\n", "6 \n");
+    TEST_EQ("imp",              "PRINT -1 IMP 0\n", "0 \n");
+    TEST_EQ("eqv",              "PRINT -1 EQV -1\n", "-1 \n");
+
+    // ============================================================
+    // String concatenation operators
+    // ============================================================
+
+    TEST_EQ("string-amp-concat",   "PRINT \"a\" & \"b\"\n", "ab\n");
+    // Unlike STR$, the & operator concatenates the digits directly with
+    // no leading space.
+    TEST_EQ("string-amp-number",   "PRINT \"num=\" & 42\n", "num=42\n");
+    TEST_EQ("string-long-concat",
+        "DIM s AS STRING\ns = \"\"\n"
+        "DIM i AS INTEGER\n"
+        "FOR i = 1 TO 5\n  s = s + \"x\"\nNEXT i\n"
+        "PRINT s\n"
+        "PRINT LEN(s)\n",
+        "xxxxx\n5 \n");
+
+    // ============================================================
+    // Fixed-length strings
+    // ============================================================
+
+    TEST_EQ("fixed-length-string",
+        "DIM s AS STRING * 5\n"
+        "s = \"hi\"\n"
+        "PRINT \"[\" + s + \"]\"\n"
+        "PRINT LEN(s)\n",
+        "[hi   ]\n5 \n");
+
+    TEST_EQ("fixed-length-truncate",
+        "DIM s AS STRING * 3\n"
+        "s = \"hello\"\n"
+        "PRINT s\n",
+        "hel\n");
+
+    // ============================================================
+    // String search / manipulation
+    // ============================================================
+
+    TEST_EQ("instr-start-pos",
+        "PRINT INSTR(3, \"abcabc\", \"a\")\n",
+        "4 \n");
+    TEST_EQ("mid-replace-assign",
+        "DIM s AS STRING\ns = \"abcdef\"\n"
+        "MID$(s, 2, 3) = \"XYZ\"\n"
+        "PRINT s\n",
+        "aXYZef\n");
+    TEST_EQ("str-leading-space-positive",
+        "PRINT \"[\" + STR$(5) + \"]\"\n",
+        "[ 5]\n");
+    TEST_EQ("str-no-leading-neg",
+        "PRINT \"[\" + STR$(-5) + \"]\"\n",
+        "[-5]\n");
+
+    // ============================================================
+    // Type conversion functions
+    // ============================================================
+
+    // DVX rounds half-away-from-zero (not VB banker's rounding).
+    TEST_EQ("cint-round-half-up", "PRINT CINT(2.5)\n", "3 \n");
+    TEST_EQ("cint-round-half-up2","PRINT CINT(3.5)\n", "4 \n");
+    TEST_EQ("cint-truncate",      "PRINT CINT(2.49)\n", "2 \n");
+    TEST_EQ("cint-negative",      "PRINT CINT(-2.5)\n", "-3 \n");
+    TEST_EQ("clng-from-double",   "PRINT CLNG(3.9)\n", "4 \n");
+    TEST_EQ("csng",               "PRINT CSNG(1.5)\n", "1.5 \n");
+    TEST_EQ("cstr-int",           "PRINT CSTR(42)\n", "42\n");
+    TEST_EQ("cdbl-of-int",        "PRINT CDBL(10) / 4\n", "2.5 \n");
+    TEST_EQ("cbool-nonzero",      "PRINT CBOOL(5)\n", "True \n");
+    TEST_EQ("cbool-zero",         "PRINT CBOOL(0)\n", "False \n");
+    TEST_EQ("cbool-neg",          "PRINT CBOOL(-42)\n", "True \n");
+    TEST_EQ("cbool-empty-string", "PRINT CBOOL(\"\")\n", "False \n");
+    TEST_EQ("cbool-nonempty-str", "PRINT CBOOL(\"hi\")\n", "True \n");
+
+    TEST_EQ("hex-positive",       "PRINT HEX$(255)\n", "FF\n");
+    TEST_EQ("hex-zero",           "PRINT HEX$(0)\n", "0\n");
+    TEST_EQ("oct-eight",          "PRINT OCT$(8)\n", "10\n");
+    TEST_EQ("oct-zero",           "PRINT OCT$(0)\n", "0\n");
+    TEST_EQ("oct-sixtythree",     "PRINT OCT$(63)\n", "77\n");
+
+    // ============================================================
+    // Math functions
+    // ============================================================
+
+    TEST_EQ("math-sqr-zero",    "PRINT SQR(0)\n", "0 \n");
+    TEST_EQ("math-sqr-float",   "PRINT SQR(2)\n", "1.41421 \n");
+    TEST_EQ("math-cos-zero",    "PRINT COS(0)\n", "1 \n");
+    TEST_EQ("math-sin-zero",    "PRINT SIN(0)\n", "0 \n");
+    TEST_EQ("math-exp-zero",    "PRINT EXP(0)\n", "1 \n");
+    TEST_EQ("math-log-one",     "PRINT LOG(1)\n", "0 \n");
+    TEST_EQ("math-abs-double",  "PRINT ABS(-3.14)\n", "3.14 \n");
+
+    // ============================================================
+    // Variable types and default initialization
+    // ============================================================
+
+    TEST_EQ("default-int-zero",
+        "DIM n AS INTEGER\nPRINT n\n",
+        "0 \n");
+    TEST_EQ("default-long-zero",
+        "DIM n AS LONG\nPRINT n\n",
+        "0 \n");
+    TEST_EQ("default-double-zero",
+        "DIM n AS DOUBLE\nPRINT n\n",
+        "0 \n");
+    // Regression: DIM AS STRING now initializes the slot to an empty
+    // string rather than leaving it as integer 0.  Previously
+    // "[" + s + "]" on an uninitialized STRING stringified as "0"
+    // because the slot type was INTEGER.
+    TEST_EQ("default-string-is-empty",
+        "DIM s AS STRING\nPRINT \"[\" + s + \"]\"\n",
+        "[]\n");
+    TEST_EQ("default-string-concat-works",
+        "DIM s AS STRING\ns = s + \"x\"\ns = s + \"y\"\nPRINT s\n",
+        "xy\n");
+
+    TEST_EQ("dollar-suffix",
+        "DIM s$\ns$ = \"hi\"\nPRINT s$\n",
+        "hi\n");
+    TEST_EQ("percent-suffix",
+        "DIM n%\nn% = 42\nPRINT n%\n",
+        "42 \n");
+    TEST_EQ("amp-suffix",
+        "DIM n&\nn& = 100000\nPRINT n&\n",
+        "100000 \n");
+    TEST_EQ("bang-suffix",
+        "DIM f!\nf! = 1.5\nPRINT f!\n",
+        "1.5 \n");
+    TEST_EQ("hash-suffix",
+        "DIM d#\nd# = 3.14159\nPRINT d#\n",
+        "3.14159 \n");
+
+    // ============================================================
+    // FOR loop variants
+    // ============================================================
+
+    TEST_EQ("for-next-var-name",
+        "DIM i AS INTEGER\n"
+        "FOR i = 1 TO 3\n  PRINT i\nNEXT i\n",
+        "1 \n2 \n3 \n");
+    TEST_EQ("for-next-no-varname",
+        "DIM i AS INTEGER\n"
+        "FOR i = 1 TO 3\n  PRINT i\nNEXT\n",
+        "1 \n2 \n3 \n");
+    TEST_EQ("for-double-varies",
+        "DIM a AS DOUBLE\n"
+        "FOR a = 1 TO 3 STEP 0.5\n  PRINT a\nNEXT a\n",
+        "1 \n1.5 \n2 \n2.5 \n3 \n");
+    TEST_EQ("for-neg-step-range",
+        "DIM i AS INTEGER\n"
+        "FOR i = 10 TO 4 STEP -2\n  PRINT i\nNEXT i\n",
+        "10 \n8 \n6 \n4 \n");
+    TEST_EQ("for-nested-exit-inner",
+        "DIM i AS INTEGER\nDIM j AS INTEGER\n"
+        "FOR i = 1 TO 3\n"
+        "  FOR j = 1 TO 3\n"
+        "    IF j = 2 THEN EXIT FOR\n"
+        "    PRINT i*10+j\n"
+        "  NEXT j\n"
+        "NEXT i\n",
+        "11 \n21 \n31 \n");
+
+    // ============================================================
+    // WHILE / DO variants
+    // ============================================================
+
+    TEST_EQ("do-loop-while-post",
+        "DIM i AS INTEGER\ni = 10\n"
+        "DO\n  PRINT i\n  i = i - 1\nLOOP WHILE i > 8\n",
+        "10 \n9 \n");
+    TEST_EQ("do-loop-until-post",
+        "DIM i AS INTEGER\ni = 0\n"
+        "DO\n  i = i + 1\n  PRINT i\nLOOP UNTIL i = 3\n",
+        "1 \n2 \n3 \n");
+    TEST_EQ("do-while-never-enters",
+        "DIM i AS INTEGER\ni = 5\n"
+        "DO WHILE i = 0\n  PRINT \"inside\"\nLOOP\n"
+        "PRINT \"after\"\n",
+        "after\n");
+
+    // ============================================================
+    // String scanning / conversion round-trip
+    // ============================================================
+
+    TEST_EQ("val-negative",     "PRINT VAL(\"-42\")\n", "-42 \n");
+    TEST_EQ("val-float",        "PRINT VAL(\"3.14\")\n", "3.14 \n");
+    TEST_EQ("val-whitespace",   "PRINT VAL(\"   123   \")\n", "123 \n");
+    TEST_EQ("val-only-letters", "PRINT VAL(\"abc\")\n", "0 \n");
+    TEST_EQ("str-then-val",
+        "DIM n AS INTEGER\nn = 42\n"
+        "PRINT VAL(STR$(n))\n",
+        "42 \n");
+
+    // ============================================================
+    // SUB / FUNCTION edge cases
+    // ============================================================
+
+    TEST_EQ("sub-zero-params",
+        "SUB greet\n  PRINT \"hi\"\nEND SUB\n"
+        "greet\n",
+        "hi\n");
+    TEST_EQ("function-returning-string",
+        "FUNCTION greeting() AS STRING\n  greeting = \"hi\"\nEND FUNCTION\n"
+        "PRINT greeting()\n",
+        "hi\n");
+    TEST_EQ("function-multi-arg",
+        "FUNCTION sum3(a AS INTEGER, b AS INTEGER, c AS INTEGER) AS INTEGER\n"
+        "  sum3 = a + b + c\nEND FUNCTION\n"
+        "PRINT sum3(1, 2, 3)\n",
+        "6 \n");
+    // OPTIONAL parameters: use `nm` as the param name; `name` is
+    // a reserved keyword for the NAME statement in DVX BASIC.
+    TEST_EQ("optional-param-omitted",
+        "SUB greet(nm AS STRING, OPTIONAL count AS INTEGER)\n"
+        "  DIM i AS INTEGER\n"
+        "  IF count = 0 THEN count = 1\n"
+        "  FOR i = 1 TO count\n    PRINT nm\n  NEXT i\n"
+        "END SUB\n"
+        "greet \"hi\", 2\n"
+        "greet \"solo\"\n",
+        "hi\nhi\nsolo\n");
+
+    // Mutual recursion: needs DECLARE forward for the later function.
+    TEST_EQ("mutual-recursion",
+        "DECLARE FUNCTION isOdd(n AS INTEGER) AS INTEGER\n"
+        "FUNCTION isEven(n AS INTEGER) AS INTEGER\n"
+        "  IF n = 0 THEN\n    isEven = 1\n  ELSE\n    isEven = isOdd(n - 1)\n  END IF\n"
+        "END FUNCTION\n"
+        "FUNCTION isOdd(n AS INTEGER) AS INTEGER\n"
+        "  IF n = 0 THEN\n    isOdd = 0\n  ELSE\n    isOdd = isEven(n - 1)\n  END IF\n"
+        "END FUNCTION\n"
+        "PRINT isEven(10)\nPRINT isOdd(10)\n",
+        "1 \n0 \n");
+
+    TEST_EQ("byval-array-element",
+        "SUB bump(BYVAL x AS INTEGER)\n  x = x + 1\nEND SUB\n"
+        "DIM a(2) AS INTEGER\n"
+        "a(0) = 5\na(1) = 10\n"
+        "bump a(0)\n"
+        "PRINT a(0)\nPRINT a(1)\n",
+        "5 \n10 \n");
+
+    TEST_EQ("byref-array-element",
+        "SUB bump(x AS INTEGER)\n  x = x + 1\nEND SUB\n"
+        "DIM a(2) AS INTEGER\n"
+        "a(0) = 5\na(1) = 10\n"
+        "bump a(0)\n"
+        "bump a(1)\nbump a(1)\n"
+        "PRINT a(0)\nPRINT a(1)\n",
+        "6 \n12 \n");
+
+    TEST_EQ("byref-array-element-2d",
+        "SUB bump(x AS INTEGER)\n  x = x + 100\nEND SUB\n"
+        "DIM a(1, 1) AS INTEGER\n"
+        "a(0, 0) = 1\na(1, 1) = 2\n"
+        "bump a(0, 0)\n"
+        "bump a(1, 1)\n"
+        "PRINT a(0, 0)\nPRINT a(1, 1)\n",
+        "101 \n102 \n");
+
+    // ============================================================
+    // Array operations
+    // ============================================================
+
+    TEST_EQ("array-negative-lbound",
+        "DIM a(-2 TO 2) AS INTEGER\n"
+        "a(-2) = 10\na(0) = 50\na(2) = 90\n"
+        "PRINT a(-2) + a(0) + a(2)\n"
+        "PRINT LBOUND(a)\nPRINT UBOUND(a)\n",
+        "150 \n-2 \n2 \n");
+
+    TEST_EQ("redim-clears",
+        "DIM a(3) AS INTEGER\n"
+        "a(0) = 99\n"
+        "REDIM a(5)\n"
+        "PRINT a(0)\n",
+        "0 \n");
+
+    // ERASE behavior in DVX: after ERASE a, accessing a(0) errors
+    // ("Not an array").  Verify that behavior.
+    TEST_RUNTIME_ERROR("erase-makes-not-array",
+        "DIM a(3) AS INTEGER\n"
+        "a(0) = 7\n"
+        "ERASE a\n"
+        "PRINT a(0)\n",
+        "");
+
+    TEST_EQ("array-3d",
+        "DIM a(1, 1, 1) AS INTEGER\n"
+        "a(0, 0, 0) = 1\n"
+        "a(1, 1, 1) = 8\n"
+        "PRINT a(0, 0, 0) + a(1, 1, 1)\n",
+        "9 \n");
+
+    TEST_EQ("array-string",
+        "DIM names(2) AS STRING\n"
+        "names(0) = \"alpha\"\nnames(1) = \"beta\"\nnames(2) = \"gamma\"\n"
+        "PRINT names(0) + \"-\" + names(2)\n",
+        "alpha-gamma\n");
+
+    // ============================================================
+    // UDTs
+    // ============================================================
+
+    TEST_EQ("udt-used-locally",
+        "TYPE P\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
+        "DIM a AS P\na.x = 3\na.y = 4\n"
+        "PRINT a.x\nPRINT a.y\n",
+        "3 \n4 \n");
+
+    TEST_EQ("udt-byref-param",
+        "TYPE P\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
+        "SUB move(pt AS P, dx AS INTEGER, dy AS INTEGER)\n"
+        "  pt.x = pt.x + dx\n  pt.y = pt.y + dy\n"
+        "END SUB\n"
+        "DIM a AS P\na.x = 1\na.y = 2\n"
+        "move a, 10, 20\n"
+        "PRINT a.x\nPRINT a.y\n",
+        "11 \n22 \n");
+
+    TEST_EQ("udt-function-arg-read",
+        "TYPE Box\n  w AS INTEGER\n  h AS INTEGER\nEND TYPE\n"
+        "FUNCTION area(b AS Box) AS LONG\n"
+        "  area = b.w * b.h\n"
+        "END FUNCTION\n"
+        "DIM r AS Box\nr.w = 6\nr.h = 7\n"
+        "PRINT area(r)\n",
+        "42 \n");
+
+    // ============================================================
+    // DATA / READ / RESTORE
+    // ============================================================
+
+    TEST_EQ("data-read-strings",
+        "DIM s AS STRING\n"
+        "READ s\nPRINT s\n"
+        "READ s\nPRINT s\n"
+        "DATA hello, world\n",
+        "hello\nworld\n");
+
+    TEST_EQ("data-read-mixed",
+        "DIM n AS INTEGER\nDIM s AS STRING\n"
+        "READ n\nREAD s\nREAD n\n"
+        "PRINT s\nPRINT n\n"
+        "DATA 10, hello, 20\n",
+        "hello\n20 \n");
+
+    TEST_EQ("restore-rewinds",
+        "DIM n AS INTEGER\n"
+        "READ n\nPRINT n\n"
+        "RESTORE\nREAD n\nPRINT n\n"
+        "DATA 5, 6, 7\n",
+        "5 \n5 \n");
+
+    // ============================================================
+    // ON ERROR additional scenarios
+    // ============================================================
+
+    TEST_EQ("err-clears-after-handler",
+        "SUB foo\n"
+        "  ON ERROR GOTO h\n"
+        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
+        "  a = 5\n  b = 0\n"
+        "  PRINT a \\ b\n"
+        "  EXIT SUB\n"
+        "  h:\n"
+        "  PRINT \"caught\"\n"
+        "END SUB\n"
+        "foo\n"
+        "PRINT \"err=\"; ERR\n",
+        "caught\nerr=0 \n");
+
+    // ============================================================
+    // Labels and GOTO
+    // ============================================================
+
+    TEST_EQ("goto-forward",
+        "GOTO done\n"
+        "PRINT \"skipped\"\n"
+        "done:\n"
+        "PRINT \"here\"\n",
+        "here\n");
+
+    TEST_EQ("goto-backward-countdown",
+        "DIM i AS INTEGER\ni = 3\n"
+        "top:\n"
+        "PRINT i\ni = i - 1\n"
+        "IF i > 0 THEN GOTO top\n",
+        "3 \n2 \n1 \n");
+
+    // ============================================================
+    // SELECT CASE edge cases
+    // ============================================================
+
+    TEST_EQ("select-case-multi-val",
+        "DIM n AS INTEGER\nn = 3\n"
+        "SELECT CASE n\n"
+        "  CASE 1, 2, 3: PRINT \"small\"\n"
+        "  CASE 4, 5, 6: PRINT \"mid\"\n"
+        "  CASE ELSE: PRINT \"big\"\n"
+        "END SELECT\n",
+        "small\n");
+
+    TEST_EQ("select-case-string",
+        "DIM s AS STRING\ns = \"b\"\n"
+        "SELECT CASE s\n"
+        "  CASE \"a\": PRINT \"A\"\n"
+        "  CASE \"b\": PRINT \"B\"\n"
+        "  CASE ELSE: PRINT \"?\"\n"
+        "END SELECT\n",
+        "B\n");
+
+    TEST_EQ("select-case-fallthrough-none",
+        // SELECT CASE does NOT fall through -- it matches first, exits.
+        "DIM n AS INTEGER\nn = 5\n"
+        "SELECT CASE n\n"
+        "  CASE 1 TO 10: PRINT \"range\"\n"
+        "  CASE IS > 0: PRINT \"pos\"\n"
+        "END SELECT\n",
+        "range\n");
+
+    // ============================================================
+    // STATIC: value persists across calls
+    // ============================================================
+
+    TEST_EQ("static-double",
+        "SUB tick\n"
+        "  STATIC n AS DOUBLE\n"
+        "  n = n + 0.5\n"
+        "  PRINT n\n"
+        "END SUB\n"
+        "tick\ntick\ntick\n",
+        "0.5 \n1 \n1.5 \n");
+
+    TEST_EQ("static-string",
+        "SUB appendHistory\n"
+        "  STATIC hist AS STRING\n"
+        "  hist = hist + \"x\"\n"
+        "  PRINT hist\n"
+        "END SUB\n"
+        "appendHistory\nappendHistory\nappendHistory\n",
+        "x\nxx\nxxx\n");
+
+    // ============================================================
+    // String array element manipulation
+    // ============================================================
+
+    TEST_EQ("string-array-accumulate",
+        "DIM parts(2) AS STRING\n"
+        "parts(0) = \"A\"\nparts(1) = \"B\"\nparts(2) = \"C\"\n"
+        "DIM result AS STRING\nresult = \"\"\n"
+        "DIM i AS INTEGER\n"
+        "FOR i = 0 TO 2\n  result = result + parts(i)\nNEXT i\n"
+        "PRINT result\n",
+        "ABC\n");
+
+    // ============================================================
+    // PRINT formatting
+    // ============================================================
+
+    TEST_EQ("print-comma-tab",
+        "PRINT \"a\", \"b\"\n",
+        "a\tb\n");
+    TEST_EQ("print-trailing-semi",
+        "PRINT \"no-newline\";\nPRINT \"follow\"\n",
+        "no-newlinefollow\n");
+    TEST_EQ("print-multi-numbers",
+        "PRINT 1; 2; 3\n",
+        "1 2 3 \n");
+
+    // ============================================================
+    // Edge cases in ON ERROR
+    // ============================================================
+
+    TEST_EQ("error-in-handler-still-fires",
+        // A second error inside the handler with no new ON ERROR should
+        // NOT loop back to the same handler.  QBASIC: second error is
+        // fatal while inErrorHandler=true.
+        "ON ERROR GOTO h\n"
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 5\nb = 0\n"
+        "PRINT a \\ b\n"
+        "END\n"
+        "h:\n"
+        "PRINT \"handler\"\n"
+        "PRINT ERR\n",
+        "handler\n11 \n");
+
+    // ============================================================
+    // GOSUB / RETURN
+    // ============================================================
+
+    TEST_EQ("gosub-multiple",
+        "GOSUB one\nGOSUB two\nEND\n"
+        "one:\nPRINT \"1\"\nRETURN\n"
+        "two:\nPRINT \"2\"\nRETURN\n",
+        "1\n2\n");
+
+    TEST_EQ("gosub-nested",
+        "GOSUB outer\nEND\n"
+        "outer:\nPRINT \"outer-in\"\nGOSUB inner\nPRINT \"outer-out\"\nRETURN\n"
+        "inner:\nPRINT \"inner\"\nRETURN\n",
+        "outer-in\ninner\nouter-out\n");
+
+    // ============================================================
+    // Complex expressions
+    // ============================================================
+
+    TEST_EQ("expr-chained-string-concat",
+        "DIM a AS STRING\nDIM b AS STRING\nDIM c AS STRING\n"
+        "a = \"foo\"\nb = \"bar\"\nc = \"baz\"\n"
+        "PRINT a + b + c + \"-end\"\n",
+        "foobarbaz-end\n");
+
+    TEST_EQ("expr-paren-nested",
+        "PRINT ((1 + 2) * 3) - (4 \\ 2)\n",
+        "7 \n");
+
+    TEST_EQ("expr-short-circuit-and",
+        // OR should evaluate both operands in classic BASIC (no
+        // short-circuit).  Just verify the result is correct.
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 0\nb = 5\n"
+        "IF a = 0 OR b = 5 THEN PRINT \"ok\"\n",
+        "ok\n");
+
+    // ============================================================
+    // IF/END IF variants
+    // ============================================================
+
+    TEST_EQ("if-else-block",
+        "DIM n AS INTEGER\nn = 1\n"
+        "IF n > 0 THEN\n  PRINT \"pos\"\nELSE\n  PRINT \"neg\"\nEND IF\n",
+        "pos\n");
+
+    TEST_EQ("if-elseif-block",
+        "DIM n AS INTEGER\nn = 15\n"
+        "IF n < 10 THEN\n  PRINT \"<10\"\n"
+        "ELSEIF n < 20 THEN\n  PRINT \"<20\"\n"
+        "ELSEIF n < 30 THEN\n  PRINT \"<30\"\n"
+        "ELSE\n  PRINT \">=30\"\nEND IF\n",
+        "<20\n");
+
+    TEST_EQ("if-nested",
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 5\nb = 10\n"
+        "IF a > 0 THEN\n"
+        "  IF b > 0 THEN\n    PRINT \"both\"\n  END IF\n"
+        "END IF\n",
+        "both\n");
+
+    // ============================================================
+    // Number parsing in source
+    // ============================================================
+
+    TEST_EQ("num-hex-literal",   "PRINT &H10\n", "16 \n");
+    TEST_EQ("num-hex-ff",        "PRINT &HFF\n", "255 \n");
+    TEST_EQ("num-octal-literal", "PRINT &O10\n", "8 \n");
+    TEST_EQ("num-octal-77",      "PRINT &O77\n", "63 \n");
+    TEST_EQ("num-binary-literal","PRINT &B1010\n", "10 \n");
+    TEST_EQ("num-binary-all",    "PRINT &B11111111\n", "255 \n");
+    TEST_EQ("num-scientific",    "PRINT 1E3\n", "1000 \n");
+    TEST_EQ("num-scientific-neg","PRINT 1E-2\n", "0.01 \n");
+
+    // ============================================================
+    // Comments
+    // ============================================================
+
+    TEST_EQ("apostrophe-comment",
+        "PRINT 1 ' this is a comment\n"
+        "PRINT 2\n",
+        "1 \n2 \n");
+    TEST_EQ("rem-comment",
+        "REM this is a comment\n"
+        "PRINT 1\n",
+        "1 \n");
+
+    // ============================================================
+    // Empty PRINT and blank lines
+    // ============================================================
+
+    TEST_EQ("print-empty",
+        "PRINT\n"
+        "PRINT \"after\"\n",
+        "\nafter\n");
+
+    // ============================================================
+    // String literal with embedded quotes (via CHR$)
+    // ============================================================
+
+    TEST_EQ("chr-quote",
+        "PRINT \"say \" + CHR$(34) + \"hi\" + CHR$(34)\n",
+        "say \"hi\"\n");
+    TEST_EQ("chr-newline-embeds",
+        "PRINT \"a\" + CHR$(10) + \"b\"\n",
+        "a\nb\n");
+
+    // ============================================================
+    // END statement halts execution
+    // ============================================================
+
+    TEST_EQ("end-halts",
+        "PRINT \"before\"\nEND\nPRINT \"after\"\n",
+        "before\n");
+
+    // ============================================================
+    // Declaration ordering: globals must be DIM'd before SUBs that
+    // reference them (the DVX compiler is single-pass).
+    // ============================================================
+
+    TEST_EQ("global-dim-then-sub",
+        "DIM counter AS INTEGER\n"
+        "SUB showIt\n  PRINT counter\nEND SUB\n"
+        "counter = 7\n"
+        "showIt\n",
+        "7 \n");
+
+    // ============================================================
+    // Type conversion through assignment
+    // ============================================================
+
+    // DVX uses dynamic typing: assigning a float to a DIM AS INTEGER
+    // slot stores the float value (the DIM only sets the INITIAL type).
+    // This differs from QBASIC but is consistent across the language.
+    TEST_EQ("dim-int-assign-float-preserves-type",
+        "DIM n AS INTEGER\n"
+        "n = 3.7\n"
+        "PRINT n\n",
+        "3.7 \n");
+
+    TEST_EQ("assign-string-from-num",
+        "DIM s AS STRING\n"
+        "s = STR$(42)\n"
+        "PRINT s\n",
+        " 42\n");
+
+    // ============================================================
+    // Arrays of doubles
+    // ============================================================
+
+    TEST_EQ("double-array",
+        "DIM d(3) AS DOUBLE\n"
+        "d(0) = 0.25\nd(1) = 0.5\nd(2) = 0.75\nd(3) = 1.0\n"
+        "PRINT d(0) + d(1) + d(2) + d(3)\n",
+        "2.5 \n");
+
+    // ============================================================
+    // Very deep recursion smoke test
+    // ============================================================
+
+    TEST_EQ("deep-recursion-50",
+        "FUNCTION countdown(n AS INTEGER) AS INTEGER\n"
+        "  IF n <= 0 THEN\n    countdown = 0\n  ELSE\n    countdown = 1 + countdown(n - 1)\n  END IF\n"
+        "END FUNCTION\n"
+        "PRINT countdown(50)\n",
+        "50 \n");
+
+    // ============================================================
+    // String in SELECT CASE with mixed case (default: case sensitive)
+    // ============================================================
+
+    TEST_EQ("select-case-str-case-sensitive",
+        "DIM s AS STRING\ns = \"Apple\"\n"
+        "SELECT CASE s\n"
+        "  CASE \"apple\": PRINT \"lower\"\n"
+        "  CASE \"Apple\": PRINT \"title\"\n"
+        "  CASE ELSE: PRINT \"?\"\n"
+        "END SELECT\n",
+        "title\n");
+
+    // ============================================================
+    // SUB-call tests through basVmCallSub (event handler path)
+    // ============================================================
+
+    TEST_SUB_EQ("subcall-globals-shared",
+        "DIM g AS INTEGER\ng = 5\n"
+        "SUB showG\n  PRINT g\n  g = g + 1\nEND SUB\n",
+        "showG",
+        "5 \n");
+
+    TEST_SUB_EQ("subcall-calls-other-sub",
+        "SUB a\n  PRINT \"a\"\n  b\nEND SUB\n"
+        "SUB b\n  PRINT \"b\"\nEND SUB\n",
+        "a",
+        "a\nb\n");
+
+    TEST_SUB_EQ("subcall-for-loop-fractional",
+        // Event handler running GfxDrawAll-style code
+        "SUB drawCircle\n"
+        "  DIM a AS DOUBLE\n"
+        "  DIM c AS INTEGER\n"
+        "  c = 0\n"
+        "  FOR a = 0 TO 6.3 STEP 0.2\n"
+        "    c = c + 1\n"
+        "  NEXT a\n"
+        "  PRINT c\n"
+        "END SUB\n",
+        "drawCircle",
+        "32 \n");
+
+    TEST_SUB_EQ("subcall-on-error-then-normal",
+        // After handler runs, a subsequent call should work normally.
+        "SUB maybeFail(shouldFail AS INTEGER)\n"
+        "  ON ERROR GOTO h\n"
+        "  IF shouldFail THEN\n"
+        "    DIM a AS INTEGER\n    DIM b AS INTEGER\n"
+        "    a = 1\n    b = 0\n"
+        "    PRINT a \\ b\n"
+        "  ELSE\n"
+        "    PRINT \"ok\"\n"
+        "  END IF\n"
+        "  EXIT SUB\n"
+        "  h:\n"
+        "  PRINT \"caught\"\n"
+        "END SUB\n"
+        "SUB driver\n"
+        "  maybeFail 0\n"
+        "  maybeFail 1\n"
+        "  maybeFail 0\n"
+        "END SUB\n",
+        "driver",
+        "ok\ncaught\nok\n");
+
+    // ============================================================
+    // Operator precedence edge cases
+    // ============================================================
+
+    TEST_EQ("prec-neg-then-pow",     "PRINT -2 ^ 2\n", "-4 \n"); // -(2^2) = -4
+    // DVX ^ is left-associative: 2^3^2 == (2^3)^2 = 64.  QBASIC doc
+    // says right-assoc but DVX chose left.  Matches actual behavior.
+    TEST_EQ("prec-pow-left-assoc",   "PRINT 2 ^ 3 ^ 2\n", "64 \n");
+    TEST_EQ("prec-mul-add",          "PRINT 2 + 3 * 4\n", "14 \n");
+    TEST_EQ("prec-eq-chain",         "PRINT 1 + 2 = 3\n", "True \n");
+    TEST_EQ("prec-string-and-num",   "PRINT \"n=\" + STR$(1 + 2)\n", "n= 3\n");
+
+    // ============================================================
+    // More string operations
+    // ============================================================
+
+    TEST_EQ("trim-both-sides",
+        "DIM s AS STRING\ns = \"  hello  \"\n"
+        "PRINT \"[\" + LTRIM$(RTRIM$(s)) + \"]\"\n",
+        "[hello]\n");
+    TEST_EQ("chr-special-chars",
+        "PRINT CHR$(9) + \"tab\"\n",
+        "\ttab\n");
+
+    // ============================================================
+    // LEN edge cases
+    // ============================================================
+
+    TEST_EQ("len-unicode-is-bytes",
+        "PRINT LEN(\"abc\")\n",
+        "3 \n");
+
+    // ============================================================
+    // Integer overflow detection
+    // ============================================================
+
+    TEST_EQ("int-max-plus-one",
+        "PRINT 32767 + 1\n",
+        "32768 \n");
+
+    // ============================================================
+    // String length tests
+    // ============================================================
+
+    TEST_EQ("string-len-after-build",
+        "DIM s AS STRING\ns = \"abc\"\ns = s + s\n"
+        "PRINT LEN(s)\n",
+        "6 \n");
+
+    // ============================================================
+    // Array bounds: writing past UBOUND is an error
+    // ============================================================
+
+    TEST_RUNTIME_ERROR("array-out-of-bounds-write",
+        "DIM a(2) AS INTEGER\na(5) = 99\n",
+        "");
+    TEST_RUNTIME_ERROR("array-out-of-bounds-read",
+        "DIM a(2) AS INTEGER\nPRINT a(5)\n",
+        "");
+    TEST_RUNTIME_ERROR("array-negative-index-no-lbound",
+        "DIM a(2) AS INTEGER\nPRINT a(-1)\n",
+        "");
+
+    // ============================================================
+    // More UDT tests
+    // ============================================================
+
+    TEST_EQ("udt-multiple-instances",
+        "TYPE P\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
+        "DIM a AS P\nDIM b AS P\n"
+        "a.x = 1\na.y = 2\n"
+        "b.x = 10\nb.y = 20\n"
+        "PRINT a.x + b.x\nPRINT a.y + b.y\n",
+        "11 \n22 \n");
+
+    TEST_EQ("udt-return-from-function-via-byref",
+        "TYPE P\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
+        "SUB setPt(pt AS P, xv AS INTEGER, yv AS INTEGER)\n"
+        "  pt.x = xv\n  pt.y = yv\n"
+        "END SUB\n"
+        "DIM a AS P\n"
+        "setPt a, 7, 8\n"
+        "PRINT a.x\nPRINT a.y\n",
+        "7 \n8 \n");
+
+    // ============================================================
+    // More FOR/loop edge cases
+    // ============================================================
+
+    TEST_EQ("for-zero-to-zero",
+        "DIM i AS INTEGER\nFOR i = 0 TO 0\n  PRINT i\nNEXT i\n",
+        "0 \n");
+
+    TEST_EQ("for-neg-to-neg",
+        "DIM i AS INTEGER\nFOR i = -3 TO -1\n  PRINT i\nNEXT i\n",
+        "-3 \n-2 \n-1 \n");
+
+    // ============================================================
+    // More SELECT CASE
+    // ============================================================
+
+    TEST_EQ("select-case-no-match-no-else",
+        "DIM n AS INTEGER\nn = 99\n"
+        "SELECT CASE n\n"
+        "  CASE 1: PRINT \"one\"\n"
+        "  CASE 2: PRINT \"two\"\n"
+        "END SELECT\n"
+        "PRINT \"done\"\n",
+        "done\n");
+
+    // ============================================================
+    // Global String and STATIC STRING from event-dispatch path
+    // ============================================================
+
+    TEST_SUB_EQ("subcall-global-string",
+        "DIM g AS STRING\ng = \"init\"\n"
+        "SUB appendX\n"
+        "  g = g + \"x\"\n"
+        "  PRINT g\n"
+        "END SUB\n",
+        "appendX",
+        "initx\n");
+
+    TEST_SUB_EQ("subcall-static-accumulates",
+        "SUB count\n"
+        "  STATIC n AS INTEGER\n"
+        "  n = n + 1\n"
+        "  PRINT n\n"
+        "END SUB\n"
+        "SUB run3\n"
+        "  count\n  count\n  count\n"
+        "END SUB\n",
+        "run3",
+        "1 \n2 \n3 \n");
+
+    // ============================================================
+    // FUNCTION returning string works in expressions
+    // ============================================================
+
+    TEST_EQ("function-string-in-expr",
+        "FUNCTION tag(s AS STRING) AS STRING\n"
+        "  tag = \"<\" + s + \">\"\nEND FUNCTION\n"
+        "PRINT tag(\"foo\") + tag(\"bar\")\n",
+        "\n");
+
+    // ============================================================
+    // Dictionary-like pattern using parallel arrays
+    // ============================================================
+
+    TEST_EQ("parallel-arrays",
+        "DIM keys(2) AS STRING\nDIM vals(2) AS INTEGER\n"
+        "keys(0) = \"a\"\nkeys(1) = \"b\"\nkeys(2) = \"c\"\n"
+        "vals(0) = 10\nvals(1) = 20\nvals(2) = 30\n"
+        "DIM i AS INTEGER\n"
+        "FOR i = 0 TO 2\n"
+        "  PRINT keys(i) + \"=\" + STR$(vals(i))\n"
+        "NEXT i\n",
+        "a= 10\nb= 20\nc= 30\n");
+
+    // ============================================================
+    // Recursive algorithms
+    // ============================================================
+
+    TEST_EQ("fib-recursive",
+        "FUNCTION fib(n AS INTEGER) AS LONG\n"
+        "  IF n <= 1 THEN\n    fib = n\n  ELSE\n    fib = fib(n - 1) + fib(n - 2)\n  END IF\n"
+        "END FUNCTION\n"
+        "PRINT fib(10)\n",
+        "55 \n");
+
+    // ============================================================
+    // String-valued DIM inside a SUB
+    // ============================================================
+
+    TEST_EQ("local-string-dim-in-sub",
+        "SUB build\n"
+        "  DIM s AS STRING\n"
+        "  s = s + \"x\"\n"
+        "  s = s + \"y\"\n"
+        "  PRINT s\n"
+        "END SUB\n"
+        "build\n"
+        "build\n",
+        "xy\nxy\n");
+
+    // ============================================================
+    // Boolean short-circuit edge case (no crash on null side)
+    // ============================================================
+
+    TEST_EQ("bool-and-normal",
+        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
+        "a = 5\nb = 10\n"
+        "IF a > 0 AND b > 0 THEN PRINT \"both-pos\"\n",
+        "both-pos\n");
+
+    // ============================================================
+    // IF with compound conditions using NOT / AND / OR
+    // ============================================================
+
+    TEST_EQ("if-not-or",
+        "DIM x AS INTEGER\nx = 5\n"
+        "IF NOT (x < 0 OR x > 100) THEN PRINT \"in-range\"\n",
+        "in-range\n");
+
+    printf("\n--------------------------\n");
+    printf("PASS: %d   FAIL: %d\n", (int)sPassCount, (int)sFailCount);
+
+    return sFailCount == 0 ? 0 : 1;
+}
diff --git a/src/apps/kpunch/dvxbasic/test_vm.c b/src/apps/kpunch/dvxbasic/test_vm.c
index 36a27c6..280060d 100644
--- a/src/apps/kpunch/dvxbasic/test_vm.c
+++ b/src/apps/kpunch/dvxbasic/test_vm.c
@@ -190,7 +190,7 @@ static void test4(void) {
     // PUSH 5 (limit); PUSH 1 (step)
     emit8(OP_PUSH_INT16); emit16(5);
     emit8(OP_PUSH_INT16); emit16(1);
-    emit8(OP_FOR_INIT); emitU16(0); emit8(1); // isLocal=1
+    emit8(OP_FOR_INIT); emitU16(0); emit8(1); emit16(0); // scope=local, skipOffset patched below
 
     // Loop body start (record PC for FOR_NEXT offset)
     int32_t loopBody = sCodeLen;
diff --git a/src/apps/kpunch/progman/progman.c b/src/apps/kpunch/progman/progman.c
index 2944712..2457551 100644
--- a/src/apps/kpunch/progman/progman.c
+++ b/src/apps/kpunch/progman/progman.c
@@ -63,7 +63,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include "dvxMem.h"
 #include "stb_ds_wrap.h"
@@ -448,9 +447,9 @@ static void scanAppsDirRecurse(const char *dirPath) {
     // Collect all entries first, close the handle, then process.
     // DOS has limited file handles; keeping a DIR open while
     // opening .app files for resource loading causes failures.
-    DIR *dir = opendir(dirPath);
+    char **names = dvxReadDir(dirPath);
 
-    if (!dir) {
+    if (!names) {
         if (sAppCount == 0) {
             dvxLog("Progman: %s directory not found", dirPath);
         }
@@ -458,19 +457,6 @@ static void scanAppsDirRecurse(const char *dirPath) {
         return;
     }
 
-    char **names = NULL;
-    struct dirent *ent;
-
-    while ((ent = readdir(dir)) != NULL) {
-        if (ent->d_name[0] == '.' && (ent->d_name[1] == '\0' || (ent->d_name[1] == '.' && ent->d_name[2] == '\0'))) {
-            continue;
-        }
-
-        arrput(names, strdup(ent->d_name));
-    }
-
-    closedir(dir);
-
     int32_t nEntries = (int32_t)arrlen(names);
 
     for (int32_t i = 0; i < nEntries; i++) {
@@ -481,14 +467,10 @@ static void scanAppsDirRecurse(const char *dirPath) {
 
         if (stat(fullPath, &st) == 0 && S_ISDIR(st.st_mode)) {
             scanAppsDirRecurse(fullPath);
-            free(names[i]);
             continue;
         }
 
-        int32_t len = (int32_t)strlen(names[i]);
-
-        if (len < 5 || strcasecmp(names[i] + len - 4, ".app") != 0 || strcasecmp(names[i], "progman.app") == 0) {
-            free(names[i]);
+        if (!dvxHasExt(names[i], ".app") || strcasecmp(names[i], "progman.app") == 0) {
             continue;
         }
 
@@ -497,6 +479,7 @@ static void scanAppsDirRecurse(const char *dirPath) {
         snprintf(newEntry.path, sizeof(newEntry.path), "%s", fullPath);
 
         // Default name from filename (without .app extension)
+        int32_t len     = (int32_t)strlen(names[i]);
         int32_t nameLen = len - 4;
 
         if (nameLen >= SHELL_APP_NAME_MAX) {
@@ -524,11 +507,10 @@ static void scanAppsDirRecurse(const char *dirPath) {
         arrput(sAppFiles, newEntry);
         sAppCount = (int32_t)arrlen(sAppFiles);
 
-        free(names[i]);
         dvxUpdate(sAc);
     }
 
-    arrfree(names);
+    dvxReadDirFree(names);
 }
 
 
diff --git a/src/apps/kpunch/resedit/resedit.frm b/src/apps/kpunch/resedit/resedit.frm
index fc5431f..f71226b 100644
--- a/src/apps/kpunch/resedit/resedit.frm
+++ b/src/apps/kpunch/resedit/resedit.frm
@@ -270,12 +270,15 @@ SUB mnuAddText_Click
 
     DIM rName AS STRING
     rName = basInputBox2("Add Text Resource", "Resource name:", "")
-    IF rName = "" THEN
+    IF basInputCancelled OR rName = "" THEN
         EXIT SUB
     END IF
 
     DIM text AS STRING
     text = basInputBox2("Add Text Resource", "Text value:", "")
+    IF basInputCancelled THEN
+        EXIT SUB
+    END IF
 
     IF ResAddText(filePath, rName, text) THEN
         ReopenAndRefresh
@@ -293,7 +296,7 @@ SUB mnuAddFile_Click
 
     DIM rName AS STRING
     rName = basInputBox2("Add File Resource", "Resource name:", "")
-    IF rName = "" THEN
+    IF basInputCancelled OR rName = "" THEN
         EXIT SUB
     END IF
 
@@ -352,6 +355,10 @@ SUB mnuEditText_Click
     DIM newText AS STRING
     newText = basInputBox2("Edit Text Resource", "Value for '" + rName + "':", oldText)
 
+    IF basInputCancelled THEN
+        EXIT SUB
+    END IF
+
     IF ResAddText(filePath, rName, newText) THEN
         ReopenAndRefresh
         LblStatus.Caption = "Updated: " + rName
diff --git a/src/include/basic/commdlg.bas b/src/include/basic/commdlg.bas
index c01eb6a..0c28a25 100644
--- a/src/include/basic/commdlg.bas
+++ b/src/include/basic/commdlg.bas
@@ -46,6 +46,10 @@ DECLARE LIBRARY "basrt"
     ' Show a modal text input box. Returns entered text, or "" if cancelled.
     DECLARE FUNCTION basInputBox2(BYVAL title AS STRING, BYVAL prompt AS STRING, BYVAL defaultText AS STRING) AS STRING
 
+    ' Returns True if the most recent basInputBox2 call was cancelled,
+    ' False if the user clicked OK (even with an empty field).
+    DECLARE FUNCTION basInputCancelled() AS INTEGER
+
     ' Show a choice dialog with a listbox. items$ is pipe-delimited
     ' (e.g. "Red|Green|Blue"). Returns chosen index (0-based), or -1.
     DECLARE FUNCTION basChoiceDialog(BYVAL title AS STRING, BYVAL prompt AS STRING, BYVAL items AS STRING, BYVAL defaultIdx AS INTEGER) AS INTEGER
diff --git a/src/libs/kpunch/libdvx/dvxApp.c b/src/libs/kpunch/libdvx/dvxApp.c
index 26a27fe..4b9525c 100644
--- a/src/libs/kpunch/libdvx/dvxApp.c
+++ b/src/libs/kpunch/libdvx/dvxApp.c
@@ -1193,6 +1193,14 @@ static void dispatchEvents(AppContextT *ctx) {
                         int32_t menuId = item->id;
                         WindowT *win   = findWindowById(ctx, ctx->popup.windowId);
                         closeAllPopups(ctx);
+                        // Consume the press so that if the menu handler
+                        // re-enters the event loop (CreateForm/Show, modal
+                        // dialog) the still-held button isn't re-dispatched
+                        // as a fresh MouseDown to whatever widget is under
+                        // the (now-closed) menu dropdown.  The matching
+                        // release is also swallowed via suppressNextMouseUp.
+                        ctx->prevMouseButtons   |= MOUSE_LEFT;
+                        ctx->suppressNextMouseUp = true;
 
                         if (win && win->onMenu) {
                             WIN_CALLBACK(ctx, win, win->onMenu(win, menuId));
@@ -1363,9 +1371,14 @@ static void dispatchEvents(AppContextT *ctx) {
         }
     }
 
-    // Handle button release on content  -- send to focused window
+    // Handle button release on content  -- send to focused window.
+    // Skip if a menu click consumed this press (prevents the stray
+    // release from firing as a click on whatever widget sits under
+    // where the menu dropdown was).
     if (!(buttons & MOUSE_LEFT) && (prevBtn & MOUSE_LEFT)) {
-        if (ctx->stack.focusedIdx >= 0) {
+        if (ctx->suppressNextMouseUp) {
+            ctx->suppressNextMouseUp = false;
+        } else if (ctx->stack.focusedIdx >= 0) {
             WindowT *win = ctx->stack.windows[ctx->stack.focusedIdx];
 
             if (win->onMouse) {
diff --git a/src/libs/kpunch/libdvx/dvxApp.h b/src/libs/kpunch/libdvx/dvxApp.h
index f446175..805511f 100644
--- a/src/libs/kpunch/libdvx/dvxApp.h
+++ b/src/libs/kpunch/libdvx/dvxApp.h
@@ -82,6 +82,10 @@ typedef struct AppContextT {
     int32_t       prevMouseX;
     int32_t       prevMouseY;
     int32_t       prevMouseButtons;
+    // Set true when a menu/popup click consumed a mouse press; the
+    // matching release is then swallowed so widgets underneath the
+    // (now-closed) menu don't receive a stray click.
+    bool          suppressNextMouseUp;
     // Double-click detection for minimized window icons: timestamps and
     // window IDs track whether two clicks land on the same icon within
     // the system double-click interval.
diff --git a/src/libs/kpunch/libdvx/dvxDraw.c b/src/libs/kpunch/libdvx/dvxDraw.c
index 24747b4..7ee5ce2 100644
--- a/src/libs/kpunch/libdvx/dvxDraw.c
+++ b/src/libs/kpunch/libdvx/dvxDraw.c
@@ -588,6 +588,65 @@ void drawHLine(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w
 }
 
 
+// ============================================================
+// calcCenteredText
+// ============================================================
+//
+// Shared by every widget that centers a text label in its own rect
+// (buttons, checkbox labels, etc.).  Centralising the arithmetic
+// avoids tiny off-by-one drift from copy-paste variations.
+
+void calcCenteredText(const BitmapFontT *font, int32_t rectX, int32_t rectY, int32_t rectW, int32_t rectH, const char *text, int32_t *outX, int32_t *outY) {
+    int32_t textW = textWidthAccel(font, text);
+
+    if (outX) {
+        *outX = rectX + (rectW - textW) / 2;
+    }
+
+    if (outY) {
+        *outY = rectY + (rectH - font->charHeight) / 2;
+    }
+}
+
+
+// ============================================================
+// drawPressableBevel
+// ============================================================
+//
+// Standard 2px button / toggle bevel.  Swaps highlight and shadow when
+// pressed to create the sunken look.  face fills the interior (pass 0
+// to leave the underlying content alone, e.g. for transparent toggle
+// buttons).
+
+void drawPressableBevel(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, bool pressed, uint32_t face, const ColorSchemeT *colors) {
+    BevelStyleT bevel;
+    bevel.highlight = pressed ? colors->windowShadow    : colors->windowHighlight;
+    bevel.shadow    = pressed ? colors->windowHighlight : colors->windowShadow;
+    bevel.face      = face;
+    bevel.width     = 2;
+    drawBevel(d, ops, x, y, w, h, &bevel);
+}
+
+
+// ============================================================
+// drawWidgetTextAccel
+// ============================================================
+//
+// Every text-bearing widget paints this same enabled/disabled branch.
+// Centralising it keeps the embossed-disabled appearance consistent
+// and removes ~5 lines of boilerplate per widget.
+
+void drawWidgetTextAccel(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, uint32_t fg, uint32_t bg, bool opaque, bool enabled, const ColorSchemeT *colors) {
+    if (!enabled) {
+        drawTextAccel(d, ops, font, x + 1, y + 1, text, colors->windowHighlight, 0, false);
+        drawTextAccel(d, ops, font, x,     y,     text, colors->windowShadow,    0, false);
+        return;
+    }
+
+    drawTextAccel(d, ops, font, x, y, text, fg, bg, opaque);
+}
+
+
 // ============================================================
 // drawInit
 // ============================================================
diff --git a/src/libs/kpunch/libdvx/dvxDraw.h b/src/libs/kpunch/libdvx/dvxDraw.h
index 0037136..95ffede 100644
--- a/src/libs/kpunch/libdvx/dvxDraw.h
+++ b/src/libs/kpunch/libdvx/dvxDraw.h
@@ -116,6 +116,24 @@ void drawTermRow(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int3
 // Windows 3.x focus rectangle convention.
 void drawFocusRect(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, uint32_t color);
 
+// Compute the (x, y) origin for rendering `text` centered both
+// horizontally and vertically inside the rect (rectX, rectY, rectW,
+// rectH).  Measures `text` with textWidthAccel (ignores & markers) and
+// the font's charHeight.  Writes results to *outX / *outY.
+void calcCenteredText(const BitmapFontT *font, int32_t rectX, int32_t rectY, int32_t rectW, int32_t rectH, const char *text, int32_t *outX, int32_t *outY);
+
+// Draw `text` with &-accelerator markers, automatically choosing
+// between the enabled and disabled (embossed) rendering paths.  Saves
+// the if/else block every widget paints around drawTextAccel /
+// drawTextAccelEmbossed.
+void drawWidgetTextAccel(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, uint32_t fg, uint32_t bg, bool opaque, bool enabled, const ColorSchemeT *colors);
+
+// Draw a pressable 2px bevel.  When pressed is true, highlight/shadow
+// colors swap to produce the "sunken" look (button pressed, toggle
+// checked).  face is the fill color; pass 0 to leave the interior
+// alone.  Used by buttons, checkboxes, toggle bars, etc.
+void drawPressableBevel(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, bool pressed, uint32_t face, const ColorSchemeT *colors);
+
 // Horizontal line (1px tall rectangle fill, but with simpler clipping).
 void drawHLine(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, uint32_t color);
 
diff --git a/src/libs/kpunch/libdvx/dvxWgtP.h b/src/libs/kpunch/libdvx/dvxWgtP.h
index 1516be6..e5df10b 100644
--- a/src/libs/kpunch/libdvx/dvxWgtP.h
+++ b/src/libs/kpunch/libdvx/dvxWgtP.h
@@ -142,6 +142,13 @@ void widgetDestroyChildren(WidgetT *w);
 // Allocation
 WidgetT *widgetAlloc(WidgetT *parent, int32_t type);
 
+// Allocate a widget of the given type PLUS a data struct of dataSize
+// bytes.  The data struct must begin with a `const char *text` field
+// (WCLASS_HAS_TEXT semantics); this field is set to strdup(text) and
+// the widget's accelKey is parsed from text.  Other fields in the data
+// struct remain zeroed.  Returns NULL on allocation failure.
+WidgetT *widgetAllocWithText(WidgetT *parent, int32_t type, size_t dataSize, const char *text);
+
 // Focus management
 WidgetT *widgetFindNextFocusable(WidgetT *root, WidgetT *after);
 WidgetT *widgetFindPrevFocusable(WidgetT *root, WidgetT *before);
diff --git a/src/libs/kpunch/libdvx/platform/dvxPlat.h b/src/libs/kpunch/libdvx/platform/dvxPlat.h
index 9e2066a..0118e16 100644
--- a/src/libs/kpunch/libdvx/platform/dvxPlat.h
+++ b/src/libs/kpunch/libdvx/platform/dvxPlat.h
@@ -312,6 +312,22 @@ const char *dvxSkipWs(const char *s);
 // new length.  buf may be NULL or empty.
 int32_t dvxTrimRight(char *buf);
 
+// Case-insensitive check that `name` ends with `ext` (which should
+// include the leading dot, e.g. ".app").  Returns false if either
+// argument is NULL or name is shorter than ext.
+bool dvxHasExt(const char *name, const char *ext);
+
+// Read all entries from `dirPath` (except "." and "..") into a
+// heap-allocated stb_ds array of malloc'd strings.  Returns NULL on
+// open failure.  An empty directory returns a valid empty array;
+// check arrlen() for count.  Caller must pass the result to
+// dvxReadDirFree when done.
+char **dvxReadDir(const char *dirPath);
+
+// Free an array returned by dvxReadDir: frees every entry and the
+// stb_ds array header.  Safe to call with NULL.
+void dvxReadDirFree(char **entries);
+
 // The platform's native directory separator as a string literal.  Use
 // with string-literal concatenation in format strings or path constants:
 //   snprintf(buf, sz, "%s" DVX_PATH_SEP "%s", dir, name);
diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c b/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
index 2b4f31a..f19289e 100644
--- a/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
+++ b/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
@@ -2312,12 +2312,15 @@ DXE_EXPORT_TABLE(sDxeExportTable)
     // --- dvx helpers (lives in dvx.exe, used by all modules) ---
     DXE_EXPORT(dvxCalloc)
     DXE_EXPORT(dvxFree)
+    DXE_EXPORT(dvxHasExt)
     DXE_EXPORT(dvxLog)
     DXE_EXPORT(dvxMalloc)
     DXE_EXPORT(dvxMemAppIdPtr)
     DXE_EXPORT(dvxMemGetAppUsage)
     DXE_EXPORT(dvxMemResetApp)
     DXE_EXPORT(dvxMemSnapshotLoad)
+    DXE_EXPORT(dvxReadDir)
+    DXE_EXPORT(dvxReadDirFree)
     DXE_EXPORT(dvxRealloc)
     DXE_EXPORT(dvxSkipWs)
     DXE_EXPORT(dvxStrdup)
diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c b/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
index f41d762..6ef6acf 100644
--- a/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
+++ b/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
@@ -29,11 +29,14 @@
 // can still use them.
 
 #include "dvxPlat.h"
+#include "thirdparty/stb_ds_wrap.h"
 
 #include 
+#include 
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #ifdef __DJGPP__
@@ -70,6 +73,67 @@ int32_t dvxTrimRight(char *buf) {
 }
 
 
+bool dvxHasExt(const char *name, const char *ext) {
+    if (!name || !ext) {
+        return false;
+    }
+
+    size_t nameLen = strlen(name);
+    size_t extLen  = strlen(ext);
+
+    if (nameLen < extLen) {
+        return false;
+    }
+
+    return strcasecmp(name + nameLen - extLen, ext) == 0;
+}
+
+
+char **dvxReadDir(const char *dirPath) {
+    if (!dirPath) {
+        return NULL;
+    }
+
+    DIR *dir = opendir(dirPath);
+
+    if (!dir) {
+        return NULL;
+    }
+
+    char          **entries = NULL;
+    struct dirent  *ent;
+
+    while ((ent = readdir(dir)) != NULL) {
+        // Skip "." and ".."
+        if (ent->d_name[0] == '.' &&
+            (ent->d_name[1] == '\0' ||
+             (ent->d_name[1] == '.' && ent->d_name[2] == '\0'))) {
+            continue;
+        }
+
+        arrput(entries, strdup(ent->d_name));
+    }
+
+    closedir(dir);
+    return entries;
+}
+
+
+void dvxReadDirFree(char **entries) {
+    if (!entries) {
+        return;
+    }
+
+    int32_t n = (int32_t)arrlen(entries);
+
+    for (int32_t i = 0; i < n; i++) {
+        free(entries[i]);
+    }
+
+    arrfree(entries);
+}
+
+
 int32_t platformChdir(const char *path) {
 #ifdef __DJGPP__
     if (path[0] && path[1] == ':') {
diff --git a/src/libs/kpunch/libdvx/widgetCore.c b/src/libs/kpunch/libdvx/widgetCore.c
index 54ec140..119b2a1 100644
--- a/src/libs/kpunch/libdvx/widgetCore.c
+++ b/src/libs/kpunch/libdvx/widgetCore.c
@@ -41,9 +41,12 @@
 // which doesn't map cleanly to an arena pattern.
 
 #include "dvxWgtP.h"
+#include "dvxDraw.h"
 #include "dvxPlat.h"
 #include "stb_ds_wrap.h"
 
+#include 
+#include 
 #include 
 
 // ============================================================
@@ -254,6 +257,31 @@ WidgetT *widgetAlloc(WidgetT *parent, int32_t type) {
 }
 
 
+WidgetT *widgetAllocWithText(WidgetT *parent, int32_t type, size_t dataSize, const char *text) {
+    WidgetT *w = widgetAlloc(parent, type);
+
+    if (!w) {
+        return NULL;
+    }
+
+    void *data = calloc(1, dataSize);
+
+    if (!data) {
+        // Widget itself is already in the tree; leave it rather than
+        // attempting a partial rollback (widget destroy is idempotent
+        // via the parent teardown).
+        dvxLog("Widget: failed to allocate %u-byte data for type %d", (unsigned)dataSize, type);
+        return w;
+    }
+
+    // The data struct must begin with a `const char *text` field.
+    *(const char **)data = text ? strdup(text) : NULL;
+    w->data     = data;
+    w->accelKey = accelParse(text);
+    return w;
+}
+
+
 int32_t widgetCountVisibleChildren(const WidgetT *w) {
     int32_t count = 0;
 
diff --git a/src/loader/loaderMain.c b/src/loader/loaderMain.c
index 1f7113d..ea9d954 100644
--- a/src/loader/loaderMain.c
+++ b/src/loader/loaderMain.c
@@ -38,7 +38,6 @@
 #include "../tools/hlpcCompile.h"
 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -175,49 +174,41 @@ static void collectGlobFiles(char ***outFiles, const char *pattern, const char *
         dirPart[1] = '\0';
     }
 
-    DIR *d = opendir(dirPart);
+    char **names = dvxReadDir(dirPart);
 
-    if (!d) {
+    if (!names) {
         return;
     }
 
-    char **names = NULL;
-    struct dirent *ent;
-
-    while ((ent = readdir(d)) != NULL) {
-        if (ent->d_name[0] == '.') {
-            continue;
-        }
-
-        arrput(names, strdup(ent->d_name));
-    }
-
-    closedir(d);
-
     int32_t nEntries = (int32_t)arrlen(names);
 
     for (int32_t i = 0; i < nEntries; i++) {
+        // Skip hidden files (dvxReadDir already strips "." and "..")
+        if (names[i][0] == '.') {
+            continue;
+        }
+
         char fullPath[DVX_MAX_PATH];
         snprintf(fullPath, sizeof(fullPath), "%s" DVX_PATH_SEP "%s", dirPart, names[i]);
 
         struct stat st;
 
-        if (stat(fullPath, &st) == 0) {
-            if (S_ISDIR(st.st_mode)) {
-                char subPattern[DVX_MAX_PATH];
-                snprintf(subPattern, sizeof(subPattern), "%s" DVX_PATH_SEP "%s", fullPath, globPart);
-                collectGlobFiles(outFiles, subPattern, excludePattern);
-            } else if (platformGlobMatch(globPart, names[i])) {
-                if (!excludePattern || !platformGlobMatch(excludePattern, names[i])) {
-                    arrput(*outFiles, strdup(fullPath));
-                }
-            }
+        if (stat(fullPath, &st) != 0) {
+            continue;
         }
 
-        free(names[i]);
+        if (S_ISDIR(st.st_mode)) {
+            char subPattern[DVX_MAX_PATH];
+            snprintf(subPattern, sizeof(subPattern), "%s" DVX_PATH_SEP "%s", fullPath, globPart);
+            collectGlobFiles(outFiles, subPattern, excludePattern);
+        } else if (platformGlobMatch(globPart, names[i])) {
+            if (!excludePattern || !platformGlobMatch(excludePattern, names[i])) {
+                arrput(*outFiles, strdup(fullPath));
+            }
+        }
     }
 
-    arrfree(names);
+    dvxReadDirFree(names);
 }
 
 
@@ -247,51 +238,38 @@ static int32_t countHcfInputFiles(const char *hcfPath) {
 
 // Count total progress steps across all .hcf files under a directory.
 static int32_t countTotalHelpSteps(const char *dirPath) {
-    DIR *dir = opendir(dirPath);
+    char **names = dvxReadDir(dirPath);
 
-    if (!dir) {
+    if (!names) {
         return 0;
     }
 
-    char **names = NULL;
-    struct dirent *ent;
-
-    while ((ent = readdir(dir)) != NULL) {
-        if (ent->d_name[0] == '.') {
-            continue;
-        }
-
-        arrput(names, strdup(ent->d_name));
-    }
-
-    closedir(dir);
-
     int32_t total    = 0;
     int32_t nEntries = (int32_t)arrlen(names);
 
     for (int32_t i = 0; i < nEntries; i++) {
+        if (names[i][0] == '.') {
+            continue;
+        }
+
         char fullPath[DVX_MAX_PATH];
         snprintf(fullPath, sizeof(fullPath), "%s" DVX_PATH_SEP "%s", dirPath, names[i]);
 
         struct stat st;
 
-        if (stat(fullPath, &st) == 0) {
-            if (S_ISDIR(st.st_mode)) {
-                total += countTotalHelpSteps(fullPath);
-            } else {
-                int32_t nameLen = (int32_t)strlen(names[i]);
-
-                if (nameLen > 4 && strcasecmp(names[i] + nameLen - 4, ".hcf") == 0) {
-                    int32_t fileCount = countHcfInputFiles(fullPath);
-                    total += hlpcProgressTotal(fileCount);
-                }
-            }
+        if (stat(fullPath, &st) != 0) {
+            continue;
         }
 
-        free(names[i]);
+        if (S_ISDIR(st.st_mode)) {
+            total += countTotalHelpSteps(fullPath);
+        } else if (dvxHasExt(names[i], ".hcf")) {
+            int32_t fileCount = countHcfInputFiles(fullPath);
+            total += hlpcProgressTotal(fileCount);
+        }
     }
 
-    arrfree(names);
+    dvxReadDirFree(names);
     return total;
 }
 
@@ -764,49 +742,36 @@ static void processHcf(const char *hcfPath, const char *hcfDir) {
 
 // Recursively scan a directory for .hcf files and process each one.
 static void processHcfDir(const char *dirPath) {
-    DIR *dir = opendir(dirPath);
+    char **names = dvxReadDir(dirPath);
 
-    if (!dir) {
+    if (!names) {
         return;
     }
 
-    char **names = NULL;
-    struct dirent *ent;
-
-    while ((ent = readdir(dir)) != NULL) {
-        if (ent->d_name[0] == '.') {
-            continue;
-        }
-
-        arrput(names, strdup(ent->d_name));
-    }
-
-    closedir(dir);
-
     int32_t nEntries = (int32_t)arrlen(names);
 
     for (int32_t i = 0; i < nEntries; i++) {
+        if (names[i][0] == '.') {
+            continue;
+        }
+
         char fullPath[DVX_MAX_PATH];
         snprintf(fullPath, sizeof(fullPath), "%s" DVX_PATH_SEP "%s", dirPath, names[i]);
 
         struct stat st;
 
-        if (stat(fullPath, &st) == 0) {
-            if (S_ISDIR(st.st_mode)) {
-                processHcfDir(fullPath);
-            } else {
-                int32_t nameLen = (int32_t)strlen(names[i]);
-
-                if (nameLen > 4 && strcasecmp(names[i] + nameLen - 4, ".hcf") == 0) {
-                    processHcf(fullPath, dirPath);
-                }
-            }
+        if (stat(fullPath, &st) != 0) {
+            continue;
         }
 
-        free(names[i]);
+        if (S_ISDIR(st.st_mode)) {
+            processHcfDir(fullPath);
+        } else if (dvxHasExt(names[i], ".hcf")) {
+            processHcf(fullPath, dirPath);
+        }
     }
 
-    arrfree(names);
+    dvxReadDirFree(names);
 }
 
 
@@ -864,35 +829,19 @@ static void scanDir(const char *dirPath, const char *ext, ModuleT **mods) {
     // Collect all entries first, close the handle, then process.
     // DOS has limited file handles; keeping a DIR open during
     // recursion or stat() causes intermittent failures.
-    DIR *dir = opendir(dirPath);
+    char **names = dvxReadDir(dirPath);
 
-    if (!dir) {
+    if (!names) {
         return;
     }
 
-    char **names = NULL;
-    struct dirent *ent;
-
-    while ((ent = readdir(dir)) != NULL) {
-        if (ent->d_name[0] == '.' && (ent->d_name[1] == '\0' || (ent->d_name[1] == '.' && ent->d_name[2] == '\0'))) {
-            continue;
-        }
-
-        arrput(names, strdup(ent->d_name));
-    }
-
-    closedir(dir);
-
-    int32_t count  = (int32_t)arrlen(names);
-    int32_t extLen = (int32_t)strlen(ext);
+    int32_t count = (int32_t)arrlen(names);
 
     for (int32_t i = 0; i < count; i++) {
         char path[DVX_MAX_PATH];
         snprintf(path, sizeof(path), "%s" DVX_PATH_SEP "%s", dirPath, names[i]);
 
-        int32_t nameLen = (int32_t)strlen(names[i]);
-
-        if (nameLen > extLen && strcasecmp(names[i] + nameLen - extLen, ext) == 0) {
+        if (dvxHasExt(names[i], ext)) {
             ModuleT mod;
             memset(&mod, 0, sizeof(mod));
             snprintf(mod.path, sizeof(mod.path), "%s", path);
@@ -905,11 +854,9 @@ static void scanDir(const char *dirPath, const char *ext, ModuleT **mods) {
                 scanDir(path, ext, mods);
             }
         }
-
-        free(names[i]);
     }
 
-    arrfree(names);
+    dvxReadDirFree(names);
 }
 
 
diff --git a/src/tools/Makefile b/src/tools/Makefile
index b8e4328..cdafc37 100644
--- a/src/tools/Makefile
+++ b/src/tools/Makefile
@@ -43,9 +43,10 @@ SYSTEMDIR = ../../bin/system
 all: $(HOSTDIR)/dvxres $(HOSTDIR)/mkicon $(HOSTDIR)/mktbicon $(HOSTDIR)/mkwgticon $(HOSTDIR)/bmp2raw $(HOSTDIR)/dvxhlpc $(SYSTEMDIR)/SPLASH.RAW $(SYSTEMDIR)/DVXHLPC.EXE $(SYSTEMDIR)/DVXRES.EXE
 
 PLATFORM_UTIL = ../libs/kpunch/libdvx/platform/dvxPlatformUtil.c
+STB_DS_IMPL   = ../libs/kpunch/libdvx/thirdparty/stb_ds_impl.c
 
-$(HOSTDIR)/dvxres: dvxres.c ../libs/kpunch/libdvx/dvxResource.c ../libs/kpunch/libdvx/dvxRes.h $(PLATFORM_UTIL) | $(HOSTDIR)
-	$(CC) $(CFLAGS) -o $@ dvxres.c ../libs/kpunch/libdvx/dvxResource.c $(PLATFORM_UTIL)
+$(HOSTDIR)/dvxres: dvxres.c ../libs/kpunch/libdvx/dvxResource.c ../libs/kpunch/libdvx/dvxRes.h $(PLATFORM_UTIL) $(STB_DS_IMPL) | $(HOSTDIR)
+	$(CC) $(CFLAGS) -o $@ dvxres.c ../libs/kpunch/libdvx/dvxResource.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
 
 $(HOSTDIR)/mkicon: mkicon.c bmpDraw.c bmpDraw.h | $(HOSTDIR)
 	$(CC) $(CFLAGS) -o $@ mkicon.c bmpDraw.c -lm
@@ -59,8 +60,8 @@ $(HOSTDIR)/mkwgticon: mkwgticon.c bmpDraw.c bmpDraw.h | $(HOSTDIR)
 $(HOSTDIR)/bmp2raw: bmp2raw.c | $(HOSTDIR)
 	$(CC) $(CFLAGS) -o $@ bmp2raw.c
 
-$(HOSTDIR)/dvxhlpc: dvxhlpc.c ../apps/kpunch/dvxhelp/hlpformat.h $(PLATFORM_UTIL) | $(HOSTDIR)
-	$(CC) $(CFLAGS) -o $@ dvxhlpc.c $(PLATFORM_UTIL)
+$(HOSTDIR)/dvxhlpc: dvxhlpc.c ../apps/kpunch/dvxhelp/hlpformat.h $(PLATFORM_UTIL) $(STB_DS_IMPL) | $(HOSTDIR)
+	$(CC) $(CFLAGS) -o $@ dvxhlpc.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
 
 $(HOSTDIR):
 	mkdir -p $(HOSTDIR)
@@ -74,8 +75,8 @@ $(BINDIR):
 $(CONFIGDIR):
 	mkdir -p $(CONFIGDIR)
 
-$(SYSTEMDIR)/DVXHLPC.EXE: dvxhlpc.c hlpcCompile.h ../apps/kpunch/dvxhelp/hlpformat.h $(PLATFORM_UTIL) | $(SYSTEMDIR)
-	$(DOSCC) $(DOSCFLAGS) -o $(SYSTEMDIR)/dvxhlpc.exe dvxhlpc.c $(PLATFORM_UTIL)
+$(SYSTEMDIR)/DVXHLPC.EXE: dvxhlpc.c hlpcCompile.h ../apps/kpunch/dvxhelp/hlpformat.h $(PLATFORM_UTIL) $(STB_DS_IMPL) | $(SYSTEMDIR)
+	$(DOSCC) $(DOSCFLAGS) -o $(SYSTEMDIR)/dvxhlpc.exe dvxhlpc.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
 	$(EXE2COFF) $(SYSTEMDIR)/dvxhlpc.exe
 	cat $(CWSDSTUB) $(SYSTEMDIR)/dvxhlpc > $@
 	rm -f $(SYSTEMDIR)/dvxhlpc $(SYSTEMDIR)/dvxhlpc.exe
@@ -87,8 +88,8 @@ $(SYSTEMDIR)/DVXHLPC.EXE: dvxhlpc.c hlpcCompile.h ../apps/kpunch/dvxhelp/hlpform
 ../../obj/loader:
 	mkdir -p ../../obj/loader
 
-$(SYSTEMDIR)/DVXRES.EXE: dvxres.c ../libs/kpunch/libdvx/dvxResource.c ../libs/kpunch/libdvx/dvxRes.h $(PLATFORM_UTIL) | $(SYSTEMDIR)
-	$(DOSCC) $(DOSCFLAGS) -o $(SYSTEMDIR)/dvxres.exe dvxres.c ../libs/kpunch/libdvx/dvxResource.c $(PLATFORM_UTIL)
+$(SYSTEMDIR)/DVXRES.EXE: dvxres.c ../libs/kpunch/libdvx/dvxResource.c ../libs/kpunch/libdvx/dvxRes.h $(PLATFORM_UTIL) $(STB_DS_IMPL) | $(SYSTEMDIR)
+	$(DOSCC) $(DOSCFLAGS) -o $(SYSTEMDIR)/dvxres.exe dvxres.c ../libs/kpunch/libdvx/dvxResource.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
 	$(EXE2COFF) $(SYSTEMDIR)/dvxres.exe
 	cat $(CWSDSTUB) $(SYSTEMDIR)/dvxres > $@
 	rm -f $(SYSTEMDIR)/dvxres $(SYSTEMDIR)/dvxres.exe
diff --git a/src/widgets/kpunch/box/widgetBox.c b/src/widgets/kpunch/box/widgetBox.c
index 6be7e17..4a8b4ef 100644
--- a/src/widgets/kpunch/box/widgetBox.c
+++ b/src/widgets/kpunch/box/widgetBox.c
@@ -73,15 +73,11 @@ void widgetFramePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitmap
 
 
 WidgetT *wgtFrame(WidgetT *parent, const char *title) {
-    WidgetT *w = widgetAlloc(parent, sFrameTypeId);
+    WidgetT *w = widgetAllocWithText(parent, sFrameTypeId, sizeof(FrameDataT), title);
 
-    if (w) {
-        FrameDataT *fd = calloc(1, sizeof(FrameDataT));
-        fd->title  = title ? strdup(title) : NULL;
-        fd->style  = FrameInE;
-        fd->color  = 0;
-        w->data    = fd;
-        w->accelKey = accelParse(title);
+    if (w && w->data) {
+        FrameDataT *fd = (FrameDataT *)w->data;
+        fd->style = FrameInE;
     }
 
     return w;
@@ -168,11 +164,7 @@ void widgetFramePaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitmap
         rectFill(d, ops, titleX - 2, titleY,
                  titleW + 4, font->charHeight, bg);
 
-        if (!w->enabled) {
-            drawTextAccelEmbossed(d, ops, font, titleX, titleY, fd->title, colors);
-        } else {
-            drawTextAccel(d, ops, font, titleX, titleY, fd->title, fg, bg, true);
-        }
+        drawWidgetTextAccel(d, ops, font, titleX, titleY, fd->title, fg, bg, true, w->enabled, colors);
     }
 }
 
diff --git a/src/widgets/kpunch/button/widgetButton.c b/src/widgets/kpunch/button/widgetButton.c
index 033ac88..3956b5f 100644
--- a/src/widgets/kpunch/button/widgetButton.c
+++ b/src/widgets/kpunch/button/widgetButton.c
@@ -70,16 +70,7 @@ void widgetButtonPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitma
 
 
 WidgetT *wgtButton(WidgetT *parent, const char *text) {
-    WidgetT *w = widgetAlloc(parent, sTypeId);
-
-    if (w) {
-        ButtonDataT *d = calloc(1, sizeof(ButtonDataT));
-        w->data        = d;
-        d->text        = text ? strdup(text) : NULL;
-        w->accelKey    = accelParse(text);
-    }
-
-    return w;
+    return widgetAllocWithText(parent, sTypeId, sizeof(ButtonDataT), text);
 }
 
 
@@ -104,27 +95,18 @@ void widgetButtonPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitma
     uint32_t fg     = w->fgColor ? w->fgColor : colors->contentFg;
     uint32_t bgFace = w->bgColor ? w->bgColor : colors->buttonFace;
 
-    BevelStyleT bevel;
-    bevel.highlight = w->pressed ? colors->windowShadow : colors->windowHighlight;
-    bevel.shadow    = w->pressed ? colors->windowHighlight : colors->windowShadow;
-    bevel.face      = bgFace;
-    bevel.width     = 2;
-    drawBevel(d, ops, w->x, w->y, w->w, w->h, &bevel);
+    drawPressableBevel(d, ops, w->x, w->y, w->w, w->h, w->pressed, bgFace, colors);
 
-    int32_t textW = textWidthAccel(font, bd->text);
-    int32_t textX = w->x + (w->w - textW) / 2;
-    int32_t textY = w->y + (w->h - font->charHeight) / 2;
+    int32_t textX;
+    int32_t textY;
+    calcCenteredText(font, w->x, w->y, w->w, w->h, bd->text, &textX, &textY);
 
     if (w->pressed) {
         textX += BUTTON_PRESS_OFFSET;
         textY += BUTTON_PRESS_OFFSET;
     }
 
-    if (!w->enabled) {
-        drawTextAccelEmbossed(d, ops, font, textX, textY, bd->text, colors);
-    } else {
-        drawTextAccel(d, ops, font, textX, textY, bd->text, fg, bgFace, true);
-    }
+    drawWidgetTextAccel(d, ops, font, textX, textY, bd->text, fg, bgFace, true, w->enabled, colors);
 
     if (w == sFocusedWidget) {
         int32_t off = w->pressed ? BUTTON_PRESS_OFFSET : 0;
diff --git a/src/widgets/kpunch/canvas/widgetCanvas.c b/src/widgets/kpunch/canvas/widgetCanvas.c
index 16f9c68..7c12bb4 100644
--- a/src/widgets/kpunch/canvas/widgetCanvas.c
+++ b/src/widgets/kpunch/canvas/widgetCanvas.c
@@ -1045,7 +1045,7 @@ static const WgtMethodDescT sMethods[] = {
 };
 
 static const WgtIfaceT sIface = {
-    .basName      = "PictureBox",
+    .basName      = "Canvas",
     .props        = NULL,
     .propCount    = 0,
     .methods      = sMethods,
diff --git a/src/widgets/kpunch/checkbox/widgetCheckbox.c b/src/widgets/kpunch/checkbox/widgetCheckbox.c
index 35d884e..157675a 100644
--- a/src/widgets/kpunch/checkbox/widgetCheckbox.c
+++ b/src/widgets/kpunch/checkbox/widgetCheckbox.c
@@ -67,16 +67,7 @@ void widgetCheckboxPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bit
 
 
 WidgetT *wgtCheckbox(WidgetT *parent, const char *text) {
-    WidgetT *w = widgetAlloc(parent, sTypeId);
-
-    if (w) {
-        CheckboxDataT *d = calloc(1, sizeof(CheckboxDataT));
-        w->data          = d;
-        d->text          = text ? strdup(text) : NULL;
-        w->accelKey      = accelParse(text);
-    }
-
-    return w;
+    return widgetAllocWithText(parent, sTypeId, sizeof(CheckboxDataT), text);
 }
 
 
@@ -183,11 +174,7 @@ void widgetCheckboxPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bit
     int32_t labelY = w->y + (w->h - font->charHeight) / 2;
     int32_t labelW = textWidthAccel(font, cd->text);
 
-    if (!w->enabled) {
-        drawTextAccelEmbossed(d, ops, font, labelX, labelY, cd->text, colors);
-    } else {
-        drawTextAccel(d, ops, font, labelX, labelY, cd->text, fg, bg, false);
-    }
+    drawWidgetTextAccel(d, ops, font, labelX, labelY, cd->text, fg, bg, false, w->enabled, colors);
 
     if (w == sFocusedWidget) {
         drawFocusRect(d, ops, labelX - 1, w->y, labelW + 2, w->h, fg);
diff --git a/src/widgets/kpunch/imageButton/widgetImageButton.c b/src/widgets/kpunch/imageButton/widgetImageButton.c
index 8284351..a7f3dca 100644
--- a/src/widgets/kpunch/imageButton/widgetImageButton.c
+++ b/src/widgets/kpunch/imageButton/widgetImageButton.c
@@ -211,12 +211,7 @@ void widgetImageButtonPaint(WidgetT *w, DisplayT *disp, const BlitOpsT *ops, con
     uint32_t bgFace = w->bgColor ? w->bgColor : colors->buttonFace;
     bool     pressed = w->pressed && w->enabled;
 
-    BevelStyleT bevel;
-    bevel.highlight = pressed ? colors->windowShadow : colors->windowHighlight;
-    bevel.shadow    = pressed ? colors->windowHighlight : colors->windowShadow;
-    bevel.face      = bgFace;
-    bevel.width     = IMAGEBUTTON_BEVEL_W;
-    drawBevel(disp, ops, w->x, w->y, w->w, w->h, &bevel);
+    drawPressableBevel(disp, ops, w->x, w->y, w->w, w->h, pressed, bgFace, colors);
 
     if (d->pixelData) {
         int32_t imgX = w->x + (w->w - d->imgW) / 2;
diff --git a/src/widgets/kpunch/label/widgetLabel.c b/src/widgets/kpunch/label/widgetLabel.c
index d1580c6..76ed9ff 100644
--- a/src/widgets/kpunch/label/widgetLabel.c
+++ b/src/widgets/kpunch/label/widgetLabel.c
@@ -59,16 +59,7 @@ void widgetLabelPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitmap
 
 
 WidgetT *wgtLabel(WidgetT *parent, const char *text) {
-    WidgetT *w = widgetAlloc(parent, sTypeId);
-
-    if (w) {
-        LabelDataT *d = calloc(1, sizeof(LabelDataT));
-        w->data        = d;
-        d->text        = text ? strdup(text) : NULL;
-        w->accelKey    = accelParse(text);
-    }
-
-    return w;
+    return widgetAllocWithText(parent, sTypeId, sizeof(LabelDataT), text);
 }
 
 
@@ -109,15 +100,10 @@ void widgetLabelPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitmap
         textX = w->x + w->w - textW;
     }
 
-    if (!w->enabled) {
-        drawTextAccelEmbossed(d, ops, font, textX, textY, ld->text, colors);
-        return;
-    }
-
     uint32_t fg = w->fgColor ? w->fgColor : colors->contentFg;
     uint32_t bg = w->bgColor ? w->bgColor : colors->contentBg;
 
-    drawTextAccel(d, ops, font, textX, textY, ld->text, fg, bg, false);
+    drawWidgetTextAccel(d, ops, font, textX, textY, ld->text, fg, bg, false, w->enabled, colors);
 }
 
 
diff --git a/src/widgets/kpunch/radio/widgetRadio.c b/src/widgets/kpunch/radio/widgetRadio.c
index 0a56b34..6c53a2c 100644
--- a/src/widgets/kpunch/radio/widgetRadio.c
+++ b/src/widgets/kpunch/radio/widgetRadio.c
@@ -95,13 +95,10 @@ static void invalidateOldSelection(WidgetT *group, int32_t oldIdx) {
 
 
 WidgetT *wgtRadio(WidgetT *parent, const char *text) {
-    WidgetT *w = widgetAlloc(parent, sRadioTypeId);
+    WidgetT *w = widgetAllocWithText(parent, sRadioTypeId, sizeof(RadioDataT), text);
 
-    if (w) {
-        RadioDataT *d = calloc(1, sizeof(RadioDataT));
-        w->data       = d;
-        d->text       = text ? strdup(text) : NULL;
-        w->accelKey   = accelParse(text);
+    if (w && w->data) {
+        RadioDataT *d = (RadioDataT *)w->data;
 
         // Auto-assign index based on position in parent
         int32_t idx = 0;
@@ -408,11 +405,7 @@ void widgetRadioPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bitmap
     int32_t labelY = w->y + (w->h - font->charHeight) / 2;
     int32_t labelW = textWidthAccel(font, rd->text);
 
-    if (!w->enabled) {
-        drawTextAccelEmbossed(d, ops, font, labelX, labelY, rd->text, colors);
-    } else {
-        drawTextAccel(d, ops, font, labelX, labelY, rd->text, fg, bg, false);
-    }
+    drawWidgetTextAccel(d, ops, font, labelX, labelY, rd->text, fg, bg, false, w->enabled, colors);
 
     if (w == sFocusedWidget) {
         drawFocusRect(d, ops, labelX - 1, w->y, labelW + 2, w->h, fg);
diff --git a/src/widgets/kpunch/textInput/textinpt.bhs b/src/widgets/kpunch/textInput/textinpt.bhs
index c887b03..f72464a 100644
--- a/src/widgets/kpunch/textInput/textinpt.bhs
+++ b/src/widgets/kpunch/textInput/textinpt.bhs
@@ -91,6 +91,7 @@ A multi-line text editing area. This is a DVX extension with no direct VB3 equiv
 .table
   Method                                          Description
   ------                                          -----------
+  AppendText text$                                Append text to the end of the buffer and invalidate the widget.
   FindNext needle$, caseSensitive, forward        Search for text. Returns True if found.
   GetWordAtCursor()                               Returns the word under the cursor.
   GoToLine line%                                  Scroll to and position cursor at the given line.
diff --git a/src/widgets/kpunch/textInput/textinpt.dhs b/src/widgets/kpunch/textInput/textinpt.dhs
index f9cc772..6cc0b61 100644
--- a/src/widgets/kpunch/textInput/textinpt.dhs
+++ b/src/widgets/kpunch/textInput/textinpt.dhs
@@ -61,6 +61,7 @@ Header: widgets/textInpt.h
 .table
   Function                                                    Description
   --------                                                    -----------
+  void wgtTextAreaAppendText(w, text)                         Append text to the end of the buffer and invalidate the widget.
   void wgtTextAreaSetColorize(w, fn, ctx)                     Set a syntax colorization callback. The callback receives each line and fills a color index array.
   void wgtTextAreaGoToLine(w, line)                           Scroll to and place the cursor on the given line number.
   void wgtTextAreaSetAutoIndent(w, enable)                    Enable or disable automatic indentation on newline.
diff --git a/src/widgets/kpunch/textInput/widgetTextInput.c b/src/widgets/kpunch/textInput/widgetTextInput.c
index 4d1b7b0..b6805de 100644
--- a/src/widgets/kpunch/textInput/widgetTextInput.c
+++ b/src/widgets/kpunch/textInput/widgetTextInput.c
@@ -240,6 +240,7 @@ static int32_t visualColToOff(const char *buf, int32_t len, int32_t lineStart, i
 WidgetT *wgtMaskedInput(WidgetT *parent, const char *mask);
 WidgetT *wgtPasswordInput(WidgetT *parent, int32_t maxLen);
 WidgetT *wgtTextArea(WidgetT *parent, int32_t maxLen);
+void wgtTextAreaAppendText(WidgetT *w, const char *text);
 bool wgtTextAreaFindNext(WidgetT *w, const char *needle, bool caseSensitive, bool forward);
 int32_t wgtTextAreaGetCursorLine(const WidgetT *w);
 int32_t wgtTextAreaGetWordAtCursor(const WidgetT *w, char *buf, int32_t bufSize);
@@ -1518,6 +1519,37 @@ WidgetT *wgtTextArea(WidgetT *parent, int32_t maxLen) {
 }
 
 
+void wgtTextAreaAppendText(WidgetT *w, const char *text) {
+    if (!w || w->type != sTextAreaTypeId || !text) {
+        return;
+    }
+
+    TextAreaDataT *ta = (TextAreaDataT *)w->data;
+
+    if (!ta->buf) {
+        return;
+    }
+
+    int32_t addLen = (int32_t)strlen(text);
+    int32_t room   = ta->bufSize - 1 - ta->len;
+
+    if (addLen > room) {
+        addLen = room;
+    }
+
+    if (addLen <= 0) {
+        return;
+    }
+
+    memcpy(ta->buf + ta->len, text, addLen);
+    ta->len             += addLen;
+    ta->buf[ta->len]     = '\0';
+    ta->cachedLines      = -1;
+    ta->cachedMaxLL      = -1;
+    wgtInvalidate(w);
+}
+
+
 bool wgtTextAreaFindNext(WidgetT *w, const char *needle, bool caseSensitive, bool forward) {
     if (!w || w->type != sTextAreaTypeId || !needle || !needle[0]) {
         return false;
@@ -3610,6 +3642,7 @@ static const WgtPropDescT sTextAreaProps[] = {
 };
 
 static const WgtMethodDescT sTextAreaMethods[] = {
+    { "AppendText",         WGT_SIG_STR,            (void *)wgtTextAreaAppendText },
     { "FindNext",           WGT_SIG_STR_BOOL_BOOL, (void *)wgtTextAreaFindNext },
     { "GetWordAtCursor",    WGT_SIG_RET_STR,       (void *)basGetWordAtCursor },
     { "GoToLine",           WGT_SIG_INT,            (void *)wgtTextAreaGoToLine },
@@ -3640,7 +3673,7 @@ static const WgtIfaceT sIfaceTextArea = {
     .props        = sTextAreaProps,
     .propCount    = 1,
     .methods      = sTextAreaMethods,
-    .methodCount  = 10,
+    .methodCount  = 11,
     .events       = NULL,
     .eventCount   = 0,
     .createSig    = WGT_CREATE_PARENT_INT,
diff --git a/src/widgets/kpunch/timer/widgetTimer.c b/src/widgets/kpunch/timer/widgetTimer.c
index dc0d5e6..47a564a 100644
--- a/src/widgets/kpunch/timer/widgetTimer.c
+++ b/src/widgets/kpunch/timer/widgetTimer.c
@@ -107,6 +107,11 @@ WidgetT *wgtTimer(WidgetT *parent, int32_t intervalMs, bool repeat) {
         w->visible     = false;
         d->intervalMs  = intervalMs;
         d->repeat      = repeat;
+        // Match VB Timer default (Enabled=True on create).  Callers can
+        // Stop() if they want it dormant.
+        d->running  = true;
+        d->lastFire = clock();
+        timerAddToActiveList(w);
     }
 
     return w;
@@ -176,6 +181,12 @@ void wgtTimerStop(WidgetT *w) {
 
 
 void wgtUpdateTimers(void) {
+    static int32_t sUpdateCount = 0;
+    sUpdateCount++;
+    if (sUpdateCount <= 3 || sUpdateCount % 100 == 0) {
+        dvxLog("[T] wgtUpdateTimers call #%d activeCount=%d",
+               (int)sUpdateCount, (int)arrlen(sActiveTimers));
+    }
     clock_t now = clock();
 
     // Iterate backwards so arrdel doesn't skip entries
@@ -192,6 +203,9 @@ void wgtUpdateTimers(void) {
         clock_t interval = (clock_t)d->intervalMs * CLOCKS_PER_SEC / 1000;
 
         if (elapsed >= interval) {
+            dvxLog("[T] timer tick: w=%p onChange=%p intervalMs=%d elapsed=%ld interval=%ld",
+                   (void *)w, (void *)w->onChange, (int)d->intervalMs,
+                   (long)elapsed, (long)interval);
             d->lastFire = now;
 
             if (w->onChange) {