// 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.

// formrt.c -- DVX BASIC form runtime implementation
//
// Bridges BASIC programs to the DVX widget system. All widget types
// are discovered dynamically via WgtIfaceT interface descriptors
// registered by .wgt DXE files. No hardcoded control types.

#include "formrt.h"
#include "../compiler/basEvents.h"
#include "../compiler/opcodes.h"
#include "../../../../libs/kpunch/serial/rs232/rs232.h"
#include "../../../../libs/kpunch/serial/seclink/secLink.h"
#include "dvxDlg.h"
#include "dvxRes.h"
#include "dvxWm.h"
#include "shellApp.h"
// Widget headers below publish inline accessor macros over
// dvx*Api() -> wgtGetApi(...) lookups, so they're lightweight
// compile-time type-safety over a runtime-dynamic dispatch -- no
// link-time dependency on the widget implementation.  AnsiTerm,
// DataCtrl, and DbGrid need direct typed calls because their C
// APIs take callback function pointers, peer widget pointers, or
// string-returning-from-string signatures that don't fit the
// basic-callable iface-method dispatch.
#include "ansiTerm/ansiTerm.h"
#include "dataCtrl/dataCtrl.h"
#include "dbGrid/dbGrid.h"
#include "frmParser.h"
#include "../../../../tools/hlpcCompile.h"
#include "thirdparty/stb_ds_wrap.h"

#include <ctype.h>
#include <dlfcn.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>

// ============================================================
// Defines
// ============================================================

#define MAX_EVENT_NAME_LEN   128
#define MENU_ID_BASE         10000

// Work-buffer / item-count caps for the BASIC dialog wrappers.
#define BAS_CHOICE_MAX_ITEMS 64
#define BAS_CHOICE_BUF_LEN   1024
#define BAS_FILTER_BUF_LEN   1024
#define BAS_INPUTBOX_BUF_LEN 512    // InputBox result text (both BASIC entry points)

// Runtime-error report buffers: the formatted detail block, the summary
// line, and the modal box that shows both.
#define BAS_ERR_DETAIL_LEN   512
#define BAS_ERR_SUMMARY_LEN  128
#define BAS_ERR_BOX_LEN      (BAS_ERR_SUMMARY_LEN + BAS_ERR_DETAIL_LEN)

// dlsym name buffer: "_" + the longest BASIC identifier.
#define BAS_MANGLED_NAME_LEN (BAS_MAX_IDENT + 2)

// Comma-separated property-name list for "Valid: ..." diagnostics.
#define BAS_PROP_LIST_LEN    256

// ResGetText result cap (static return buffer).
#define BAS_RES_TEXT_LEN     1024

// Module-level form runtime pointer for onFormClose callback
static BasFormRtT   *sFormRt = NULL;

// True only while frmParse is running inside basFormRtLoadFrm.  The
// parser calls basFormRtLoadForm via onFormBegin to create the bare form
// before it starts firing onCtrlBegin callbacks; while this is set that
// nested LoadForm must neither consult the .frm cache (it would recurse
// into basFormRtLoadFrm forever) nor run the form's init code (it would
// fire against a control list that is still empty).  basFormRtLoadFrm
// clears it again BEFORE its post-parse tail fires Resize/Load, so a
// 'Load Form2' from Form1_Load takes the normal cached-.frm path.
static bool sLoadingFrm = false;

// 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;


// ============================================================
// Module-scope declarations
// ============================================================

// Parse a filter string into FileFilterT entries.
//
// Format: "Label (pattern)|Label (pattern)|..."
// The pattern is extracted from inside the parentheses.
// Example: "Images (*.bmp;*.png;*.jpg)|Text (*.txt)|All Files (*.*)"
//
// If no parentheses, the entire entry is used as both label and pattern.
// If no pipe, treat as a single pattern for backward compatibility.

// No fixed file-filter cap: filters are heap-allocated per parse call.


typedef struct {
    int     (*open)(int, int32_t, int, char, int, int);
    int     (*close)(int);
    int     (*read)(int, char *, int);
    int     (*write)(int, const char *, int);
    int     (*writeBuf)(int, const char *, int);
    int     (*getRxBuffered)(int);
    int     (*clearRxBuffer)(int);
    int     (*getUartType)(int);
    int     (*getBase)(int);
    int     (*getIrq)(int);
    int     (*setBase)(int, int);
    int     (*setIrq)(int, int);
} SerApiT;

static SerApiT sSerApi;

static bool    sSerApiResolved = false;

// Per-COM raw serial state for terminal attach
#define SER_RECV_BUF    4096

typedef struct {
    bool     attached;
    WidgetT *term;
    int32_t  com;      // 0-based COM port index
} SerAttachT;

static SerAttachT sSerAttach[RS232_NUM_PORTS];


// SecLink function pointers (resolved lazily)
typedef struct {
    void *(*open)(int, int32_t, int, char, int, int, void (*)(void *, const uint8_t *, int, uint8_t), void *);
    void   (*close)(void *);
    int    (*handshake)(void *);
    bool   (*isReady)(void *);
    int    (*poll)(void *);
    int    (*send)(void *, const uint8_t *, int, uint8_t, bool, bool);
    int    (*sendBuf)(void *, const uint8_t *, int, uint8_t, bool);
    int    (*getPending)(void *);
} CommSecLinkApiT;

static CommSecLinkApiT sCommApi;

static bool            sCommApiResolved = false;

// Per-channel receive ring buffer
typedef struct {
    uint8_t  data[SECLINK_CHAN_BUF_SIZE];
    int32_t  head;
    int32_t  tail;
} CommChanBufT;

typedef struct {
    bool          active;
    void         *link;                          // SecLinkT*
    CommChanBufT  channels[SECLINK_NUM_CHANNELS];   // per-channel receive buffers
    WidgetT      *attachedTerm;                  // AnsiTerm widget if attached
    int32_t       termChannel;                   // channel for terminal I/O
    bool          termEncrypt;                    // encrypt terminal traffic
} CommSlotT;

// Dynamic array of heap-allocated comm slots (stb_ds).  Each slot is
// ~512KB (channel buffers), so we allocate lazily on CommOpen and free
// on CommClose.  Entries may be NULL where a slot has been closed --
// open scans for the first hole and reuses it, keeping handles stable.
static CommSlotT **sCommSlots = NULL;

// hlpcCompile function pointer (resolved lazily)
typedef int32_t (*HlpcCompileFnT)(const char **, int32_t, const char *, const char *, const char *, int32_t, void *, void *);

static HlpcCompileFnT sHlpcCompile = NULL;

static bool            sHlpcResolved = false;

// shellLoadAppWithArgs function pointer (resolved lazily)
typedef int32_t (*ShellLoadAppFnT)(void *, const char *, const char *);

static ShellLoadAppFnT sShellLoadApp = NULL;

static bool            sShellLoadResolved = false;

// Shell idle-handler registry (resolved lazily; mirrors sShellLoadApp).
// The secLink/serial pollers register here instead of overwriting the
// context's single idleCallback slot -- the old scheme clobbered the
// shell's cooperative-yield handler and never restored it.
typedef void (*ShellIdleFnT)(void (*)(void *), void *);

static ShellIdleFnT sShellRegisterIdle   = NULL;

static ShellIdleFnT sShellUnregisterIdle = NULL;

static bool         sShellIdleResolved   = false;

// Maximum number of simultaneously open resource handles
#define RES_MAX_HANDLES 8

static DvxResHandleT *sResHandles[RES_MAX_HANDLES];


// ============================================================
// .frm parsing context (used by frmLoad_* callbacks)
// ============================================================

typedef struct {
    char    caption[FRM_MAX_LINE_LEN];
    char    name[BAS_MAX_IDENT];
    int32_t level;
    bool    checked;
    bool    radioCheck;
    bool    enabled;
    bool    visible;      // level==0: true = menu bar entry, false = popup-only
} BasFrmMenuItemT;


typedef struct {
    BasFormRtT       *rt;
    BasFormT         *form;
    BasControlT      *current;
    WidgetT          *parentStack[FRM_MAX_NESTING];
    int32_t           nestDepth;
    bool              containerStack[FRM_MAX_NESTING];
    int32_t           containerDepth;
    BasFrmMenuItemT  *menuItems;   // stb_ds array
    int32_t           curMenuItemIdx;
} BasFrmLoadCtxT;


// ============================================================
// Property descriptor tables (see formrt.h: BasPropDescT)
// ============================================================

// Form-object property ids.  Each entry designates its slot in
// sFormProps[], so the enum and the table cannot drift apart.
typedef enum {
    FORM_PROP_NAME,
    FORM_PROP_CAPTION,
    FORM_PROP_WIDTH,
    FORM_PROP_HEIGHT,
    FORM_PROP_LEFT,
    FORM_PROP_TOP,
    FORM_PROP_VISIBLE,
    FORM_PROP_RESIZABLE,
    FORM_PROP_AUTOSIZE,
    FORM_PROP_CENTERED,
    FORM_PROP_LAYOUT,
    FORM_PROP_CONTEXTMENU,
    FORM_PROP_HELPTOPIC,
    FORM_PROP_COUNT
} BasFormPropE;

static const BasPropDescT sFormProps[FORM_PROP_COUNT] = {
    [FORM_PROP_NAME]        = { "Name",        WGT_IFACE_STRING, true, false },
    [FORM_PROP_CAPTION]     = { "Caption",     WGT_IFACE_STRING, true, true  },
    [FORM_PROP_WIDTH]       = { "Width",       WGT_IFACE_INT,    true, true  },
    [FORM_PROP_HEIGHT]      = { "Height",      WGT_IFACE_INT,    true, true  },
    [FORM_PROP_LEFT]        = { "Left",        WGT_IFACE_INT,    true, true  },
    [FORM_PROP_TOP]         = { "Top",         WGT_IFACE_INT,    true, true  },
    [FORM_PROP_VISIBLE]     = { "Visible",     WGT_IFACE_BOOL,   true, true  },
    [FORM_PROP_RESIZABLE]   = { "Resizable",   WGT_IFACE_BOOL,   true, true  },
    [FORM_PROP_AUTOSIZE]    = { "AutoSize",    WGT_IFACE_BOOL,   true, true  },
    [FORM_PROP_CENTERED]    = { "Centered",    WGT_IFACE_BOOL,   true, true  },
    [FORM_PROP_LAYOUT]      = { "Layout",      WGT_IFACE_STRING, true, false },
    [FORM_PROP_CONTEXTMENU] = { "ContextMenu", WGT_IFACE_STRING, true, true  },
    [FORM_PROP_HELPTOPIC]   = { "HelpTopic",   WGT_IFACE_STRING, true, true  },
};

// Common control property ids (every non-menu control, consulted after
// the widget's own interface so a widget-declared property of the same
// name -- Timer.Enabled, Data.Caption, TabControl.TabIndex -- wins).
typedef enum {
    CTRL_PROP_NAME,
    CTRL_PROP_LEFT,
    CTRL_PROP_TOP,
    CTRL_PROP_WIDTH,
    CTRL_PROP_HEIGHT,
    CTRL_PROP_MINWIDTH,
    CTRL_PROP_MINHEIGHT,
    CTRL_PROP_MAXWIDTH,
    CTRL_PROP_MAXHEIGHT,
    CTRL_PROP_WEIGHT,
    CTRL_PROP_VISIBLE,
    CTRL_PROP_ENABLED,
    CTRL_PROP_READONLY,
    CTRL_PROP_BACKCOLOR,
    CTRL_PROP_FORECOLOR,
    CTRL_PROP_CAPTION,
    CTRL_PROP_TEXT,
    CTRL_PROP_TOOLTIPTEXT,
    CTRL_PROP_CONTEXTMENU,
    CTRL_PROP_HELPTOPIC,
    CTRL_PROP_DATASOURCE,
    CTRL_PROP_DATAFIELD,
    CTRL_PROP_LISTCOUNT,
    CTRL_PROP_COUNT
} BasCtrlPropE;

static const BasPropDescT sCtrlProps[CTRL_PROP_COUNT] = {
    [CTRL_PROP_NAME]        = { "Name",        WGT_IFACE_STRING, true, false },
    [CTRL_PROP_LEFT]        = { "Left",        WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_TOP]         = { "Top",         WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_WIDTH]       = { "Width",       WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_HEIGHT]      = { "Height",      WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_MINWIDTH]    = { "MinWidth",    WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_MINHEIGHT]   = { "MinHeight",   WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_MAXWIDTH]    = { "MaxWidth",    WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_MAXHEIGHT]   = { "MaxHeight",   WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_WEIGHT]      = { "Weight",      WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_VISIBLE]     = { "Visible",     WGT_IFACE_BOOL,   true, true  },
    [CTRL_PROP_ENABLED]     = { "Enabled",     WGT_IFACE_BOOL,   true, true  },
    [CTRL_PROP_READONLY]    = { "ReadOnly",    WGT_IFACE_BOOL,   true, true  },
    [CTRL_PROP_BACKCOLOR]   = { "BackColor",   WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_FORECOLOR]   = { "ForeColor",   WGT_IFACE_INT,    true, true  },
    [CTRL_PROP_CAPTION]     = { "Caption",     WGT_IFACE_STRING, true, true  },
    [CTRL_PROP_TEXT]        = { "Text",        WGT_IFACE_STRING, true, true  },
    [CTRL_PROP_TOOLTIPTEXT] = { "ToolTipText", WGT_IFACE_STRING, true, true  },
    [CTRL_PROP_CONTEXTMENU] = { "ContextMenu", WGT_IFACE_STRING, true, true  },
    [CTRL_PROP_HELPTOPIC]   = { "HelpTopic",   WGT_IFACE_STRING, true, true  },
    [CTRL_PROP_DATASOURCE]  = { "DataSource",  WGT_IFACE_STRING, true, true  },
    [CTRL_PROP_DATAFIELD]   = { "DataField",   WGT_IFACE_STRING, true, true  },
    [CTRL_PROP_LISTCOUNT]   = { "ListCount",   WGT_IFACE_INT,    true, false },
};


// Common method names, indexed by BasCommonMethodE.
static const char *const sCommonMethodNames[BAS_CM_COUNT] = {
    [BAS_CM_SETFOCUS] = "SetFocus",
    [BAS_CM_REFRESH] = "Refresh",
    [BAS_CM_SETREADONLY] = "SetReadOnly",
    [BAS_CM_SETENABLED] = "SetEnabled",
    [BAS_CM_SETVISIBLE] = "SetVisible",
    [BAS_CM_POPUPMENU] = "PopupMenu",
    [BAS_CM_CREATEMENU] = "CreateMenu",
    [BAS_CM_ADDMENUITEM] = "AddMenuItem",
    [BAS_CM_ADDMENUSEPARATOR] = "AddMenuSeparator",
    [BAS_CM_ADDSUBMENU] = "AddSubMenu",
    [BAS_CM_DESTROYMENU] = "DestroyMenu",
    [BAS_CM_ADDBUTTON] = "AddButton",
    [BAS_CM_ADDTEXTBUTTON] = "AddTextButton",
    [BAS_CM_ADDSEPARATOR] = "AddSeparator",
    [BAS_CM_CLEAR] = "Clear",
    [BAS_CM_BUTTONCOUNT] = "ButtonCount",
};


// ============================================================
// Prototypes
// ============================================================

static BasFormT *allocForm(BasFormRtT *rt, const char *formName, bool resizable, bool centered, int32_t width, int32_t height);
int32_t basChoiceDialog(const char *title, const char *prompt, const char *items, int32_t defaultIdx);
BasValueT basExternCall(void *ctx, void *funcPtr, const char *libName, const char *funcName, BasValueT *args, int32_t argc, uint8_t retType);
void *basExternResolve(void *ctx, const char *libName, const char *funcName);
const char *basFileOpen(const char *title, const char *filter);
const char *basFileSave(const char *title, const char *filter);
void basFormRtBindVm(BasFormRtT *rt);
BasValueT basFormRtCallMethod(void *ctx, void *ctrlRef, const char *methodName, BasValueT *args, int32_t argc);
int32_t basFormRtCommonMethodId(const char *name);
const BasPropDescT *basFormRtCommonProps(int32_t *count);
BasFormRtT *basFormRtCreate(AppContextT *ctx, BasVmT *vm, BasModuleT *module);
WidgetT *basFormRtCreateContentBox(WidgetT *root, const char *layout);
void *basFormRtCreateCtrl(void *ctx, void *formRef, const char *typeName, const char *ctrlName);
void *basFormRtCreateCtrlEx(void *ctx, void *formRef, const char *typeName, const char *ctrlName, void *parentRef);
void *basFormRtCreateForm(void *ctx, const char *formName, int32_t width, int32_t height);
WindowT *basFormRtCreateFormWindow(AppContextT *ctx, const char *title, const char *layout, bool resizable, bool centered, bool autoSize, int32_t width, int32_t height, int32_t left, int32_t top, WidgetT **outRoot, WidgetT **outContentBox);
void basFormRtDestroy(BasFormRtT *rt);
void basFormRtEventLoop(BasFormRtT *rt);
const BasPropDescT *basFormRtFindCommonProp(const char *name);
void *basFormRtFindCtrl(void *ctx, void *formRef, const char *ctrlName);
void *basFormRtFindCtrlIdx(void *ctx, void *formRef, const char *ctrlName, int32_t index);
const BasPropDescT *basFormRtFindFormProp(const char *name);
bool basFormRtFireEvent(BasFormRtT *rt, BasFormT *form, const char *ctrlName, const char *eventName);
bool basFormRtFireEventArgs(BasFormRtT *rt, BasFormT *form, const char *ctrlName, const char *eventName, const BasValueT *args, int32_t argCount);
static bool basFormRtFireEventWithCancel(BasFormRtT *rt, BasFormT *form, const char *ctrlName, const char *eventName);
const BasPropDescT *basFormRtFormProps(int32_t *count);
BasValueT basFormRtGetProp(void *ctx, void *ctrlRef, const char *propName);
void basFormRtHideForm(void *ctx, void *formRef);
static void basFormRtInitFormVars(BasFormRtT *rt, BasFormT *form, bool runInit);
static BasStringT *basFormRtInputBox(void *ctx, const char *prompt, const char *title, const char *defaultText);
void basFormRtLoadAllForms(BasFormRtT *rt, const char *startupFormName);
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 basFormRtRegisterFrm(BasFormRtT *rt, const char *formName, const char *source, int32_t sourceLen);
void basFormRtRemoveCtrl(void *ctx, void *formRef, const char *ctrlName);
void basFormRtRunSimple(BasFormRtT *rt);
void basFormRtRuntimeError(BasFormRtT *rt, const char *summary, const char *detailFmt, ...);
void basFormRtSerialShutdown(void);
void basFormRtSetEvent(void *ctx, void *ctrlRef, const char *eventName, const char *handlerName);
void basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT value);
void basFormRtShowForm(void *ctx, void *formRef, bool modal);
static void basFormRtTeardownForm(BasFormRtT *rt, BasFormT *form);
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 uint32_t basNativeCall(void *funcPtr, const uint32_t *nativeArgs, int32_t nativeCount, bool fpReturn, double *fpResult);
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);
void CommClose(int32_t handle);
void CommDetach(int32_t handle);
static CommSlotT *commGetSlot(int32_t handle);
int32_t CommHandshake(int32_t handle);
static void commIdlePoll(void *ctx);
int32_t CommIsReady(int32_t handle);
static void commOnRecv(void *ctx, const uint8_t *data, int len, uint8_t channel);
int32_t CommOpen(int32_t com, int32_t baud, int32_t dataBits, const char *parity, int32_t stopBits, int32_t handshake);
int32_t CommPending(int32_t handle);
int32_t CommPoll(int32_t handle);
const char *CommRecv(int32_t handle, int32_t channel);
static bool commResolveApi(void);
int32_t CommSend(int32_t handle, const char *data, int32_t channel, int32_t encrypt);
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 const char *ctrlDisplayName(const BasControlT *ctrl);
static BasControlT *ctrlFromWidget(WidgetT *w, BasFormRtT **outRt);
static void destroyFormNow(BasFormRtT *rt, BasFormT *form);
static void detachFormForUnload(BasFormRtT *rt, BasFormT *form);
static BasControlT *findCtrlByWidget(BasFormT *form, const WidgetT *w);
static BasControlT *findCtrlInForm(BasFormT *form, const char *name);
static WidgetT *findCtrlWidgetByName(const char *name);
static BasFormT *findFormByWindow(const WindowT *win);
static BasControlT *findMenuProxyInForm(BasFormT *form, const char *name);
static BasControlT *findNamedObjectInForm(BasFormT *form, const char *name);
static BasFrmPopupMenuT *findPopupMenu(BasFormT *form, const char *name);
static const BasPropDescT *findPropDesc(const BasPropDescT *table, int32_t count, const char *name);
static void fireCtrlEvent(BasFormRtT *rt, BasControlT *ctrl, const char *eventName, const BasValueT *args, int32_t argCount);
static void fireCtrlEventOut(BasFormRtT *rt, BasControlT *ctrl, const char *eventName, const BasValueT *args, int32_t argCount, BasValueT *outArgs, int32_t outArgCount);
static bool fireEventArgsOut(BasFormRtT *rt, BasFormT *form, const char *ctrlName, const char *eventName, const BasValueT *args, int32_t argCount, BasValueT *outArgs, int32_t outArgCount);
static void formatPropNames(const BasPropDescT *table, int32_t count, bool forWrite, char *buf, int32_t bufSize);
static void formRtCommIdleSync(void);
static void formRtDetachTermsForForm(BasFormT *form);
static bool formRtDetachTermsForWidget(WidgetT *widget);
static void formRtSerIdleSync(void);
static void freeControl(BasControlT *ctrl);
static void frmLoadOnCtrlBegin(void *userData, const char *typeName, const char *name);
static void frmLoadOnCtrlEnd(void *userData);
static void frmLoadOnCtrlProp(void *userData, const char *key, const char *value);
static bool frmLoadOnFormBegin(void *userData, const char *name);
static void frmLoadOnFormProp(void *userData, const char *key, const char *value);
static void frmLoadOnMenuBegin(void *userData, const char *name, int32_t level);
static void frmLoadOnMenuEnd(void *userData);
static void frmLoadOnMenuProp(void *userData, const char *key, const char *value);
static BasValueT getCommonProp(BasControlT *ctrl, const char *propName, bool *handled);
static BasValueT getFormProp(BasFormRtT *rt, BasFormT *frm, const char *propName);
static BasValueT getIfaceProp(const WgtIfaceT *iface, WidgetT *w, const char *propName, bool *handled);
int32_t HelpCompile(const char *inputFile, const char *outputFile);
void HelpView(const char *hlpFile);
static int32_t ifaceEnumIndex(const WgtPropDescT *p, const char *name);
static const WgtMethodDescT *ifaceFindMethod(const WgtIfaceT *iface, const char *name, uint8_t sig);
static const char *ifaceGetStringProp(const BasControlT *ctrl, const char *propName);
static bool menuContainsMenu(const MenuT *tree, const MenuT *target);
static int32_t nextMenuItemId(BasFormT *form);
static void onFormActivate(WindowT *win);
static void onFormClose(WindowT *win);
static void onFormDeactivate(WindowT *win);
static void onFormMenu(WindowT *win, int32_t menuId);
static void onFormResize(WindowT *win, int32_t newW, int32_t newH);
static void onWidgetBlur(WidgetT *w);
static void onWidgetChange(WidgetT *w);
static void onWidgetClick(WidgetT *w);
static void onWidgetDblClick(WidgetT *w);
static void onWidgetFocus(WidgetT *w);
static void onWidgetKeyDown(WidgetT *w, int32_t keyCode, int32_t shift);
static void onWidgetKeyPress(WidgetT *w, int32_t keyAscii);
static void onWidgetKeyUp(WidgetT *w, int32_t keyCode, int32_t shift);
static void onWidgetMouseDown(WidgetT *w, int32_t button, int32_t x, int32_t y);
static void onWidgetMouseMove(WidgetT *w, int32_t button, int32_t x, int32_t y);
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 const char *popupMenuNameFor(const BasFormT *form, const MenuT *menu);
static void refreshDetailControls(BasFormT *form, BasControlT *masterCtrl);
static void removeCtrlTree(BasFormRtT *rt, BasFormT *form, BasControlT *ctrl);
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);
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);
static void resizeIfaceBuffer(BasControlT *ctrl);
const char *ResName(int32_t handle, int32_t index);
static MenuItemT *resolveMenuItem(BasFormT *form, int32_t menuId, bool *fromBar);
static BasFormT *resolveOwningForm(BasFormRtT *rt, const BasProcEntryT *proc);
static bool resolveShellIdle(void);
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);
int32_t ResType(int32_t handle, int32_t index);
static bool rtCallHandler(BasFormRtT *rt, BasFormT *eventForm, const BasProcEntryT *proc, int32_t codeAddr, const BasValueT *args, int32_t argCount, BasValueT *outArgs, int32_t outArgCount);
static void rtEventEnter(BasFormRtT *rt);
static void rtEventLeave(BasFormRtT *rt);
void SerAttach(int32_t com, const char *termCtrlName);
int32_t SerAvailable(int32_t com);
void SerClose(int32_t com);
void SerDetach(int32_t com);
void SerFlush(int32_t com);
int32_t SerGetBase(int32_t com);
int32_t SerGetIrq(int32_t com);
int32_t SerGetUart(int32_t com);
static void serIdlePoll(void *ctx);
int32_t SerOpen(int32_t com, int32_t baud, int32_t dataBits, const char *parity, int32_t stopBits, int32_t handshake);
const char *SerRead(int32_t com);
static bool serResolveApi(void);
void SerSetBase(int32_t com, int32_t base);
void SerSetIrq(int32_t com, int32_t irq);
static int32_t serTermRead(void *ctx, uint8_t *buf, int32_t maxLen);
static int32_t serTermWrite(void *ctx, const uint8_t *data, int32_t len);
int32_t SerWrite(int32_t com, const char *data);
static bool setCommonProp(BasControlT *ctrl, const char *propName, BasValueT value);
static void setControlString(char *dst, int32_t dstSize, BasValueT value);
static void setFormProp(BasFormRtT *rt, BasFormT *frm, const char *propName, BasValueT value);
static bool setIfaceProp(const WgtIfaceT *iface, WidgetT *w, const char *propName, BasValueT value);
int32_t SQLAffected(int32_t db);
void SQLClose(int32_t db);
int32_t SQLEof(int32_t rs);
const char *SQLError(int32_t db);
int32_t SQLExec(int32_t db, const char *sql);
const char *SQLField(int32_t rs, const char *colName);
int32_t SQLFieldCount(int32_t rs);
double SQLFieldDbl(int32_t rs, int32_t col);
int32_t SQLFieldInt(int32_t rs, int32_t col);
const char *SQLFieldName(int32_t rs, int32_t col);
const char *SQLFieldText(int32_t rs, int32_t col);
void SQLFreeResult(int32_t rs);
int32_t SQLNext(int32_t rs);
int32_t SQLOpen(const char *path);
int32_t SQLQuery(int32_t db, const char *sql);
static void unloadFormNow(BasFormRtT *rt, BasFormT *form);
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 bool widgetHasAncestor(const WidgetT *w, const WidgetT *ancestor);
static void wireWidgetEvents(WidgetT *w, BasControlT *ctrl);
static BasValueT zeroValue(void);


// Create the window and BasFormT for a new form and register it in
// rt->forms.  Shared by CreateForm and the bare-form path of LoadForm;
// callers set any form-specific frm* fields afterwards.
static BasFormT *allocForm(BasFormRtT *rt, const char *formName, bool resizable, bool centered, int32_t width, int32_t height) {
    WidgetT *root;
    WidgetT *contentBox;
    WindowT *win = basFormRtCreateFormWindow(rt->ctx, formName, "VBox", resizable, centered, false, width, height, 0, 0, &root, &contentBox);

    if (!win) {
        return NULL;
    }

    BasFormT *form = (BasFormT *)calloc(1, sizeof(BasFormT));

    if (!form) {
        dvxDestroyWindow(rt->ctx, win);
        return NULL;
    }

    arrput(rt->forms, form);

    snprintf(form->name, BAS_MAX_IDENT, "%s", formName);
    snprintf(form->frmLayout, sizeof(form->frmLayout), "VBox");
    win->onClose     = onFormClose;
    win->onResize    = onFormResize;
    win->onFocus     = onFormActivate;
    win->onBlur      = onFormDeactivate;
    form->window     = win;
    form->root       = root;
    form->contentBox = contentBox;
    form->ctx        = rt->ctx;
    form->vm         = rt->vm;
    form->module     = rt->module;

    // Synthetic control for form-level property access.  Its name field
    // stays empty on purpose: a control name buffer cannot hold a full
    // BAS_MAX_IDENT name, so form->name is the single source of
    // truth and readers go through ctrlDisplayName().
    form->formCtrl.widget = root;
    form->formCtrl.form   = form;

    return form;
}


int32_t basChoiceDialog(const char *title, const char *prompt, const char *items, int32_t defaultIdx) {
    if (!sFormRt || !items) {
        return -1;
    }

    // Split pipe-delimited items string into an array
    static char itemBuf[BAS_CHOICE_BUF_LEN];
    snprintf(itemBuf, sizeof(itemBuf), "%s", items);

    const char *ptrs[BAS_CHOICE_MAX_ITEMS];
    int32_t count = 0;
    char *tok     = itemBuf;

    while (*tok && count < BAS_CHOICE_MAX_ITEMS) {
        ptrs[count++] = tok;
        char *sep = strchr(tok, '|');

        if (sep) {
            *sep = '\0';
            tok  = sep + 1;
        } else {
            break;
        }
    }

    if (count == 0) {
        return -1;
    }

    int32_t chosen = 0;

    if (dvxChoiceDialog(sFormRt->ctx, title, prompt, ptrs, count, defaultIdx, &chosen)) {
        return chosen;
    }

    return -1;
}


BasValueT basExternCall(void *ctx, void *funcPtr, const char *libName, const char *funcName, BasValueT *args, int32_t argc, uint8_t retType) {
    (void)ctx;
    (void)libName;
    (void)funcName;

    // Each DOUBLE/SINGLE arg consumes two 32-bit slots, so the marshalled
    // word array must hold 2x the max call-arg count.  The VM rejects
    // argc > BAS_VM_MAX_CALL_ARGS before reaching here.
    uint32_t nativeArgs[BAS_VM_MAX_CALL_ARGS * 2];
    char    *tempStrings[BAS_VM_MAX_CALL_ARGS];
    int32_t  tempStringCount = 0;
    int32_t  nativeCount     = 0;

    for (int32_t i = 0; i < argc; i++) {
        switch (args[i].type) {
            case BAS_TYPE_STRING: {
                BasStringT *s = basValFormatString(args[i]);
                char *cstr = strdup(s->data);
                basStringUnref(s);
                nativeArgs[nativeCount++] = (uint32_t)(uintptr_t)cstr;
                tempStrings[tempStringCount++] = cstr;
                break;
            }

            case BAS_TYPE_SINGLE: {
                union { double d; uint32_t u[2]; } conv;
                conv.d = (double)args[i].sngVal;
                nativeArgs[nativeCount++] = conv.u[0];
                nativeArgs[nativeCount++] = conv.u[1];
                break;
            }

            case BAS_TYPE_DOUBLE: {
                union { double d; uint32_t u[2]; } conv;
                conv.d = args[i].dblVal;
                nativeArgs[nativeCount++] = conv.u[0];
                nativeArgs[nativeCount++] = conv.u[1];
                break;
            }

            default: {
                nativeArgs[nativeCount++] = (uint32_t)(int32_t)basValToNumber(args[i]);
                break;
            }
        }
    }

    BasValueT result;
    memset(&result, 0, sizeof(result));

    double dblResult = 0.0;
    bool   fpReturn  = (retType == BAS_TYPE_DOUBLE || retType == BAS_TYPE_SINGLE);

    uint32_t rawResult = basNativeCall(funcPtr, nativeArgs, nativeCount, fpReturn, &dblResult);

    // The i386 ABI leaves the upper bits of a sub-32-bit return (bool,
    // int16_t) undefined, so narrow by the declared return type instead
    // of trusting all of EAX.
    if (fpReturn) {
        result = basValDouble(dblResult);
    } else if (retType == BAS_TYPE_STRING) {
        const char *str = (const char *)(uintptr_t)rawResult;
        result = basValStringFromC(str ? str : "");
    } else if (retType == BAS_TYPE_BOOLEAN) {
        result = basValBool((uint8_t)rawResult != 0);
    } else if (retType == BAS_TYPE_INTEGER) {
        result = basValLong((int16_t)rawResult);
    } else {
        result = basValLong((int32_t)rawResult);
    }

    for (int32_t i = 0; i < tempStringCount; i++) {
        free(tempStrings[i]);
    }

    return result;
}


void *basExternResolve(void *ctx, const char *libName, const char *funcName) {
    (void)ctx;
    (void)libName;

    char mangledName[BAS_MANGLED_NAME_LEN];
    snprintf(mangledName, sizeof(mangledName), "_%s", funcName);

    return dlsym(NULL, mangledName);
}


const char *basFileOpen(const char *title, const char *filter) {
    if (!sFormRt) {
        return "";
    }

    FileFilterT *filters = NULL;
    char filterBuf[BAS_FILTER_BUF_LEN];
    int32_t count = parseFileFilters(filter, &filters, filterBuf, sizeof(filterBuf));

    static char path[DVX_MAX_PATH];
    path[0] = '\0';

    bool ok = dvxFileDialog(sFormRt->ctx, title, FD_OPEN, NULL, filters, count, path, sizeof(path));

    free(filters);

    if (ok) {
        return path;
    }

    return "";
}


const char *basFileSave(const char *title, const char *filter) {
    if (!sFormRt) {
        return "";
    }

    FileFilterT *filters = NULL;
    char filterBuf[BAS_FILTER_BUF_LEN];
    int32_t count = parseFileFilters(filter, &filters, filterBuf, sizeof(filterBuf));

    static char path[DVX_MAX_PATH];
    path[0] = '\0';

    bool ok = dvxFileDialog(sFormRt->ctx, title, FD_SAVE, NULL, filters, count, path, sizeof(path));

    free(filters);

    if (ok) {
        return path;
    }

    return "";
}


void basFormRtBindVm(BasFormRtT *rt) {
    BasUiCallbacksT ui;
    memset(&ui, 0, sizeof(ui));

    ui.getProp      = basFormRtGetProp;
    ui.setProp      = basFormRtSetProp;
    ui.callMethod   = basFormRtCallMethod;
    ui.createCtrl   = basFormRtCreateCtrl;
    ui.findCtrl     = basFormRtFindCtrl;
    ui.findCtrlIdx  = basFormRtFindCtrlIdx;
    ui.loadForm     = basFormRtLoadForm;
    ui.unloadForm   = basFormRtUnloadForm;
    ui.showForm     = basFormRtShowForm;
    ui.hideForm     = basFormRtHideForm;
    ui.msgBox       = basFormRtMsgBox;
    ui.inputBox     = basFormRtInputBox;
    ui.createForm   = basFormRtCreateForm;
    ui.createCtrlEx = basFormRtCreateCtrlEx;
    ui.setEvent     = basFormRtSetEvent;
    ui.removeCtrl   = basFormRtRemoveCtrl;
    ui.ctx          = rt;

    basVmSetUiCallbacks(rt->vm, &ui);
}


BasValueT basFormRtCallMethod(void *ctx, void *ctrlRef, const char *methodName, BasValueT *args, int32_t argc) {
    BasFormRtT *rt = (BasFormRtT *)ctx;
    BasControlT *ctrl = (BasControlT *)ctrlRef;

    if (!ctrl) {
        basFormRtRuntimeError(rt,
            "Method call on unknown control",
            "Control: %s\nMethod: %s\nControl was not found on any loaded form.",
            (rt && rt->lastLookupName[0]) ? rt->lastLookupName : "?",
            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 : "?",
            ctrlDisplayName(ctrl));
        return zeroValue();
    }

    // Interface descriptor methods
    const WgtIfaceT *iface = ctrl->iface;

    if (iface) {
        for (int32_t i = 0; i < iface->methodCount; i++) {
            if (strcasecmp(iface->methods[i].name, methodName) != 0) {
                continue;
            }

            const WgtMethodDescT *m = &iface->methods[i];
            WidgetT *w = ctrl->widget;

            switch (m->sig) {
                case WGT_SIG_VOID:
                    ((void (*)(WidgetT *))m->fn)(w);
                    return zeroValue();

                case WGT_SIG_INT:
                    if (argc >= 1) {
                        ((void (*)(WidgetT *, int32_t))m->fn)(w, (int32_t)basValToNumber(args[0]));
                    }
                    return zeroValue();

                case WGT_SIG_BOOL:
                    if (argc >= 1) {
                        ((void (*)(WidgetT *, bool))m->fn)(w, basValIsTruthy(args[0]));
                    }
                    return zeroValue();

                case WGT_SIG_STR:
                    if (argc >= 1) {
                        BasStringT *s = basValFormatString(args[0]);
                        ((void (*)(WidgetT *, const char *))m->fn)(w, s->data);
                        basStringUnref(s);
                    }
                    return zeroValue();

                case WGT_SIG_INT_INT:
                    if (argc >= 2) {
                        ((void (*)(WidgetT *, int32_t, int32_t))m->fn)(w, (int32_t)basValToNumber(args[0]), (int32_t)basValToNumber(args[1]));
                    }
                    return zeroValue();

                case WGT_SIG_INT_BOOL:
                    if (argc >= 2) {
                        ((void (*)(WidgetT *, int32_t, bool))m->fn)(w, (int32_t)basValToNumber(args[0]), basValIsTruthy(args[1]));
                    }
                    return zeroValue();

                case WGT_SIG_RET_INT: {
                    int32_t v = ((int32_t (*)(const WidgetT *))m->fn)(w);
                    return basValLong(v);
                }

                case WGT_SIG_RET_BOOL: {
                    bool v = ((bool (*)(const WidgetT *))m->fn)(w);
                    return basValBool(v);
                }

                case WGT_SIG_RET_BOOL_INT: {
                    if (argc >= 1) {
                        bool v = ((bool (*)(const WidgetT *, int32_t))m->fn)(w, (int32_t)basValToNumber(args[0]));
                        return basValBool(v);
                    }
                    return basValBool(false);
                }

                case WGT_SIG_INT3:
                    if (argc >= 3) {
                        ((void (*)(WidgetT *, int32_t, int32_t, int32_t))m->fn)(w, (int32_t)basValToNumber(args[0]), (int32_t)basValToNumber(args[1]), (int32_t)basValToNumber(args[2]));
                    }
                    return zeroValue();

                case WGT_SIG_INT4:
                    if (argc >= 4) {
                        ((void (*)(WidgetT *, int32_t, int32_t, int32_t, int32_t))m->fn)(w, (int32_t)basValToNumber(args[0]), (int32_t)basValToNumber(args[1]), (int32_t)basValToNumber(args[2]), (int32_t)basValToNumber(args[3]));
                    }
                    return zeroValue();

                case WGT_SIG_RET_INT_INT_INT: {
                    if (argc >= 2) {
                        int32_t v = ((int32_t (*)(const WidgetT *, int32_t, int32_t))m->fn)(w, (int32_t)basValToNumber(args[0]), (int32_t)basValToNumber(args[1]));
                        return basValLong(v);
                    }
                    return zeroValue();
                }

                case WGT_SIG_INT5:
                    if (argc >= 5) {
                        ((void (*)(WidgetT *, int32_t, int32_t, int32_t, int32_t, int32_t))m->fn)(w, (int32_t)basValToNumber(args[0]), (int32_t)basValToNumber(args[1]), (int32_t)basValToNumber(args[2]), (int32_t)basValToNumber(args[3]), (int32_t)basValToNumber(args[4]));
                    }
                    return zeroValue();

                case WGT_SIG_RET_STR_INT: {
                    if (argc >= 1) {
                        const char *s = ((const char *(*)(const WidgetT *, int32_t))m->fn)(w, (int32_t)basValToNumber(args[0]));
                        return basValStringFromC(s ? s : "");
                    }
                    return basValStringFromC("");
                }

                case WGT_SIG_RET_STR_INT_INT: {
                    if (argc >= 2) {
                        const char *s = ((const char *(*)(const WidgetT *, int32_t, int32_t))m->fn)(w, (int32_t)basValToNumber(args[0]), (int32_t)basValToNumber(args[1]));
                        return basValStringFromC(s ? s : "");
                    }
                    return basValStringFromC("");
                }

                case WGT_SIG_INT_INT_STR:
                    if (argc >= 3) {
                        BasStringT *s = basValFormatString(args[2]);
                        ((void (*)(WidgetT *, int32_t, int32_t, const char *))m->fn)(w, (int32_t)basValToNumber(args[0]), (int32_t)basValToNumber(args[1]), s->data);
                        basStringUnref(s);
                    }
                    return zeroValue();

                case WGT_SIG_STR_BOOL_BOOL: {
                    if (argc >= 3) {
                        BasStringT *s = basValFormatString(args[0]);
                        bool v = ((bool (*)(WidgetT *, const char *, bool, bool))m->fn)(w, s->data, basValIsTruthy(args[1]), basValIsTruthy(args[2]));
                        basStringUnref(s);
                        return basValBool(v);
                    }
                    return basValBool(false);
                }

                case WGT_SIG_STR_STR_BOOL: {
                    if (argc >= 3) {
                        BasStringT *s1 = basValFormatString(args[0]);
                        BasStringT *s2 = basValFormatString(args[1]);
                        int32_t v = ((int32_t (*)(WidgetT *, const char *, const char *, bool))m->fn)(w, s1->data, s2->data, basValIsTruthy(args[2]));
                        basStringUnref(s1);
                        basStringUnref(s2);
                        return basValLong(v);
                    }
                    return zeroValue();
                }

                case WGT_SIG_RET_STR: {
                    const char *s = ((const char *(*)(const WidgetT *))m->fn)(w);
                    return basValStringFromC(s ? s : "");
                }

                case WGT_SIG_STR_INT:
                    if (argc >= 2) {
                        BasStringT *s = basValFormatString(args[0]);
                        ((void (*)(WidgetT *, const char *, int32_t))m->fn)(w, s->data, (int32_t)basValToNumber(args[1]));
                        basStringUnref(s);
                    }
                    return zeroValue();

                case WGT_SIG_INT_STR:
                    if (argc >= 2) {
                        BasStringT *s = basValFormatString(args[1]);
                        ((void (*)(WidgetT *, int32_t, const char *))m->fn)(w, (int32_t)basValToNumber(args[0]), s->data);
                        basStringUnref(s);
                    }
                    return zeroValue();

                case WGT_SIG_STR_STR:
                    if (argc >= 2) {
                        BasStringT *a = basValFormatString(args[0]);
                        BasStringT *b = basValFormatString(args[1]);
                        ((void (*)(WidgetT *, const char *, const char *))m->fn)(w, a->data, b->data);
                        basStringUnref(a);
                        basStringUnref(b);
                    }
                    return zeroValue();

                case WGT_SIG_STR_BOOL:
                    if (argc >= 2) {
                        BasStringT *s = basValFormatString(args[0]);
                        ((void (*)(WidgetT *, const char *, bool))m->fn)(w, s->data, basValIsTruthy(args[1]));
                        basStringUnref(s);
                    }
                    return zeroValue();
            }
        }
    }

    // Common methods (SetFocus, Refresh)
    return callCommonMethod(ctrl, methodName, args, argc);
}


int32_t basFormRtCommonMethodId(const char *name) {
    for (int32_t i = 0; i < BAS_CM_COUNT; i++) {
        if (strcasecmp(sCommonMethodNames[i], name) == 0) {
            return i;
        }
    }

    return BAS_CM_NONE;
}


const BasPropDescT *basFormRtCommonProps(int32_t *count) {
    *count = CTRL_PROP_COUNT;
    return sCtrlProps;
}


BasFormRtT *basFormRtCreate(AppContextT *ctx, BasVmT *vm, BasModuleT *module) {
    BasFormRtT *rt = (BasFormRtT *)calloc(1, sizeof(BasFormRtT));

    if (!rt) {
        return NULL;
    }

    rt->ctx    = ctx;
    rt->vm     = vm;
    rt->module = module;

    sFormRt = rt;
    basFormRtBindVm(rt);
    return rt;
}


WidgetT *basFormRtCreateContentBox(WidgetT *root, const char *layout) {
    if (!layout || !layout[0] || strcasecmp(layout, "VBox") == 0) {
        return root;
    }

    const char *wgtName = wgtFindByBasName(layout);

    if (wgtName) {
        const WgtIfaceT *iface = wgtGetIface(wgtName);

        if (iface && iface->isContainer && iface->createSig == WGT_CREATE_PARENT) {
            const void *api = wgtGetApi(wgtName);

            if (api) {
                WidgetT *(*createFn)(WidgetT *) = *(WidgetT *(*const *)(WidgetT *))api;
                WidgetT *box = createFn(root);

                if (box) {
                    box->weight = WGT_WEIGHT_FILL;
                    return box;
                }
            }
        }
    }

    return root;
}


void *basFormRtCreateCtrl(void *ctx, void *formRef, const char *typeName, const char *ctrlName) {
    return basFormRtCreateCtrlEx(ctx, formRef, typeName, ctrlName, NULL);
}


void *basFormRtCreateCtrlEx(void *ctx, void *formRef, const char *typeName, const char *ctrlName, void *parentRef) {
    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;
    }

    // Resolve VB name to DVX widget type name
    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;
    }

    // Parent widget: a container control's widget when given, otherwise
    // the form's content box.
    WidgetT *parent = NULL;

    if (parentRef) {
        parent = ((BasControlT *)parentRef)->widget;
    }

    if (!parent) {
        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;
    }

    wgtSetName(widget, ctrlName);

    BasControlT *ctrl = (BasControlT *)calloc(1, sizeof(BasControlT));

    if (!ctrl) {
        wgtDestroy(widget);
        return NULL;
    }

    ctrl->index  = -1;
    snprintf(ctrl->name,     BAS_MAX_IDENT, "%s", ctrlName);
    snprintf(ctrl->typeName, BAS_MAX_IDENT, "%s", typeName ? typeName : "");
    ctrl->widget = widget;
    ctrl->form   = form;
    ctrl->iface  = wgtGetIface(wgtTypeName);
    arrput(form->controls, ctrl);

    // Wire up event callbacks (key/mouse/scroll included so dynamically
    // created controls match the .frm-load path; dispatch no-ops when
    // BASIC has no matching handler).
    wireWidgetEvents(widget, ctrl);

    return ctrl;
}


void *basFormRtCreateForm(void *ctx, const char *formName, int32_t width, int32_t height) {
    BasFormRtT *rt = (BasFormRtT *)ctx;

    if (!rt || !formName) {
        return NULL;
    }

    // Check if form already exists
    for (int32_t i = 0; i < (int32_t)arrlen(rt->forms); i++) {
        if (strcasecmp(rt->forms[i]->name, formName) == 0) {
            return rt->forms[i];
        }
    }

    if (width <= 0) {
        width = BAS_DEFAULT_FORM_W;
    }

    if (height <= 0) {
        height = BAS_DEFAULT_FORM_H;
    }

    BasFormT *form = allocForm(rt, formName, true, true, width, height);

    if (!form) {
        return NULL;
    }

    form->frmWidth     = width;
    form->frmHeight    = height;
    form->frmResizable = true;
    form->frmCentered  = true;

    // Allocate per-form variable storage from module metadata
    basFormRtInitFormVars(rt, form, true);

    return form;
}


WindowT *basFormRtCreateFormWindow(AppContextT *ctx, const char *title, const char *layout, bool resizable, bool centered, bool autoSize, int32_t width, int32_t height, int32_t left, int32_t top, WidgetT **outRoot, WidgetT **outContentBox) {
    int32_t defW = (width > 0)  ? width  : BAS_DEFAULT_FORM_W;
    int32_t defH = (height > 0) ? height : BAS_DEFAULT_FORM_H;

    WindowT *win = dvxCreateWindowCentered(ctx, title, defW, defH, resizable);

    if (!win) {
        return NULL;
    }

    win->visible = false;

    WidgetT *root = wgtInitWindow(ctx, win);

    if (!root) {
        dvxDestroyWindow(ctx, win);
        return NULL;
    }

    WidgetT *contentBox = basFormRtCreateContentBox(root, layout);

    if (autoSize) {
        dvxFitWindow(ctx, win);
    } else if (width > 0 && height > 0) {
        dvxResizeWindow(ctx, win, width, height);
    }

    if (centered) {
        win->x = (ctx->display.width - win->w) / 2;
        win->y = (ctx->display.height - win->h) / 2;
    } else if (left > 0 || top > 0) {
        win->x = left;
        win->y = top;
    }

    *outRoot       = root;
    *outContentBox = contentBox;
    return win;
}


void basFormRtDestroy(BasFormRtT *rt) {
    if (!rt) {
        return;
    }

    if (sFormRt == rt) {
        sFormRt = NULL;
    }

    // Close serial/secLink and drop the idle pollers before tearing down the
    // forms whose terminal widgets they reference.  Also invoked from the
    // stub's _appShutdown hook so a force-killed app gets the same cleanup.
    basFormRtSerialShutdown();

    for (int32_t i = 0; i < (int32_t)arrlen(rt->forms); i++) {
        destroyFormNow(rt, rt->forms[i]);
    }

    arrfree(rt->forms);

    // Drain the deferred-free queues: forms queued for unload were
    // detached from rt->forms, so the loop above missed them.  This runs
    // only on normal appMain exit and IDE stop -- a force-kill never
    // reaches basFormRtDestroy; there the shell's per-app window scan
    // reclaims the windows before dlclose.  destroyFormNow (not
    // unloadFormNow) so nothing re-touches vm fields on a dying runtime.
    while (arrlen(rt->pendingCtrlFree) > 0) {
        BasControlT *ctrl = arrpop(rt->pendingCtrlFree);

        freeControl(ctrl);
    }

    while (arrlen(rt->pendingUnload) > 0) {
        BasFormT *form = arrpop(rt->pendingUnload);

        destroyFormNow(rt, form);
    }

    arrfree(rt->pendingCtrlFree);
    arrfree(rt->pendingUnload);

    // Free .frm source cache
    for (int32_t i = 0; i < rt->frmCacheCount; i++) {
        free(rt->frmCache[i].frmSource);
    }

    arrfree(rt->frmCache);
    free(rt);
}


void basFormRtEventLoop(BasFormRtT *rt) {
    if (!rt || !rt->ctx || !rt->vm) {
        return;
    }

    while (rt->ctx->running && arrlen(rt->forms) > 0 && !rt->terminated) {
        if (!dvxUpdate(rt->ctx)) {
            break;
        }
    }
}


const BasPropDescT *basFormRtFindCommonProp(const char *name) {
    return findPropDesc(sCtrlProps, CTRL_PROP_COUNT, name);
}


void *basFormRtFindCtrl(void *ctx, void *formRef, const char *ctrlName) {
    BasFormRtT *rt = (BasFormRtT *)ctx;
    BasFormT *form = (BasFormT *)formRef;

    // Remember the last name asked for so a subsequent method/prop
    // call that lands on a NULL ctrl can still name the culprit.
    if (rt && ctrlName) {
        snprintf(rt->lastLookupName, sizeof(rt->lastLookupName), "%s", ctrlName);
    }

    if (!form) {
        return NULL;
    }

    // The current form first (a local miss is expected for cross-form
    // access; don't spam the log), then every other loaded form: 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).
    BasControlT *hit = findNamedObjectInForm(form, ctrlName);

    if (hit || !rt) {
        return hit;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(rt->forms); i++) {
        BasFormT *other = rt->forms[i];

        if (other == form) {
            continue;
        }

        hit = findNamedObjectInForm(other, ctrlName);

        if (hit) {
            return hit;
        }
    }

    return NULL;
}


void *basFormRtFindCtrlIdx(void *ctx, void *formRef, const char *ctrlName, int32_t index) {
    BasFormRtT *rt = (BasFormRtT *)ctx;
    BasFormT *form = (BasFormT *)formRef;

    if (rt && ctrlName) {
        snprintf(rt->lastLookupName, sizeof(rt->lastLookupName),
                 "%s(%d)", ctrlName, (int)index);
    }

    if (!form) {
        return NULL;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(form->controls); i++) {
        if (form->controls[i]->index == index && strcasecmp(form->controls[i]->name, ctrlName) == 0) {
            return form->controls[i];
        }
    }

    return NULL;
}


const BasPropDescT *basFormRtFindFormProp(const char *name) {
    return findPropDesc(sFormProps, FORM_PROP_COUNT, name);
}


bool basFormRtFireEvent(BasFormRtT *rt, BasFormT *form, const char *ctrlName, const char *eventName) {
    return basFormRtFireEventArgs(rt, form, ctrlName, eventName, NULL, 0);
}


bool basFormRtFireEventArgs(BasFormRtT *rt, BasFormT *form, const char *ctrlName, const char *eventName, const BasValueT *args, int32_t argCount) {
    return fireEventArgsOut(rt, form, ctrlName, eventName, args, argCount, NULL, 0);
}


// Fire an event that has a Cancel parameter (first arg, Integer).
// Returns true if the handler set Cancel to non-zero.
static bool basFormRtFireEventWithCancel(BasFormRtT *rt, BasFormT *form, const char *ctrlName, const char *eventName) {
    BasValueT cancelArg = basValLong(0);
    BasValueT outCancel = basValLong(0);

    fireEventArgsOut(rt, form, ctrlName, eventName, &cancelArg, 1, &outCancel, 1);

    bool cancelled = basValIsTruthy(outCancel);
    basValRelease(&outCancel);
    return cancelled;
}


const BasPropDescT *basFormRtFormProps(int32_t *count) {
    *count = FORM_PROP_COUNT;
    return sFormProps;
}


BasValueT basFormRtGetProp(void *ctx, void *ctrlRef, const char *propName) {
    BasFormRtT *rt = (BasFormRtT *)ctx;
    BasControlT *ctrl = (BasControlT *)ctrlRef;

    if (!ctrl) {
        basFormRtRuntimeError(rt,
            "Read of unknown control",
            "Control: %s\nProperty: %s\nControl was not found on any loaded form.",
            (rt && rt->lastLookupName[0]) ? rt->lastLookupName : "?",
            propName ? propName : "?");
        return zeroValue();
    }

    // Menu item properties.  resolveMenuItem handles both menu-bar items and
    // popup-only items (a Visible=False menu with no bar entry), so
    // Checked/Enabled read the item's real state in either case.
    if (ctrl->menuId > 0 && ctrl->form) {
        MenuItemT *item = resolveMenuItem(ctrl->form, ctrl->menuId, NULL);

        if (item) {
            if (strcasecmp(propName, "Checked") == 0) {
                return basValBool(item->checked);
            }

            if (strcasecmp(propName, "Enabled") == 0) {
                return basValBool(item->enabled);
            }

            if (strcasecmp(propName, "Name") == 0) {
                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.",
            ctrlDisplayName(ctrl), propName ? propName : "?");
        return zeroValue();
    }

    // Form-level properties use the window and BasFormT, not the root widget
    if (ctrl->form && ctrl == &ctrl->form->formCtrl) {
        return getFormProp(rt, ctrl->form, propName);
    }

    // Widget-declared properties first, so a widget's own Enabled /
    // Caption / TabIndex overrides the generic common-property handling.
    bool      handled = false;
    BasValueT val     = zeroValue();

    if (ctrl->iface) {
        val = getIfaceProp(ctrl->iface, ctrl->widget, propName, &handled);
    }

    if (!handled) {
        val = getCommonProp(ctrl, propName, &handled);
    }

    if (handled) {
        return val;
    }

    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();
}


void basFormRtHideForm(void *ctx, void *formRef) {
    BasFormRtT *rt   = (BasFormRtT *)ctx;
    BasFormT   *form = (BasFormT *)formRef;

    if (!form) {
        basFormRtRuntimeError(rt,
            "Hide on unknown form",
            "The form reference resolved to NULL; check that the form name exists.");
        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;
    }

    // dvxHideWindow owns the modal-window invariant: a hidden form must
    // not stay registered as the modal window.
    dvxHideWindow(rt->ctx, form->window);
}


// Allocate per-form variable storage from module metadata and, when
// runInit is true, run the form's init code.  Allocation is idempotent:
// if form->formVars is already set (e.g. a bare form created by the
// nested .frm LoadForm), it is left untouched.  Single source of truth
// for the four formerly-duplicated init blocks.
static void basFormRtInitFormVars(BasFormRtT *rt, BasFormT *form, bool runInit) {
    if (!rt || !form || !rt->module || !rt->module->formVarInfo) {
        return;
    }

    for (int32_t j = 0; j < rt->module->formVarInfoCount; j++) {
        if (strcasecmp(rt->module->formVarInfo[j].formName, form->name) != 0) {
            continue;
        }

        if (!form->formVars) {
            int32_t vc = rt->module->formVarInfo[j].varCount;

            if (vc > 0) {
                form->formVars     = (BasValueT *)calloc(vc, sizeof(BasValueT));
                form->formVarCount = vc;
            }
        }

        int32_t initAddr = rt->module->formVarInfo[j].initCodeAddr;

        if (runInit && initAddr >= 0 && rt->vm) {
            // Init code has no BasProcEntryT: pass proc = NULL so the
            // form-scope vars bind to the form itself.
            rtCallHandler(rt, form, NULL, initAddr, NULL, 0, NULL, 0);
        }

        break;
    }
}


static BasStringT *basFormRtInputBox(void *ctx, const char *prompt, const char *title, const char *defaultText) {
    BasFormRtT *rt = (BasFormRtT *)ctx;
    char buf[BAS_INPUTBOX_BUF_LEN];

    buf[0] = '\0';

    if (!dvxInputBox(rt->ctx, title, prompt, defaultText, buf, sizeof(buf))) {
        return NULL;
    }

    return basStringNew(buf, (int32_t)strlen(buf));
}


void basFormRtLoadAllForms(BasFormRtT *rt, const char *startupFormName) {
    if (!rt) {
        return;
    }

    // Load .frm text cache entries (skip if already loaded)
    for (int32_t i = 0; i < rt->frmCacheCount; i++) {
        basFormRtLoadForm(rt, rt->frmCache[i].formName);
    }

    // Show the startup form (named or first)
    int32_t formCount = (int32_t)arrlen(rt->forms);

    if (formCount > 0) {
        BasFormT *startup = rt->forms[0];

        if (startupFormName && startupFormName[0]) {
            for (int32_t i = 0; i < formCount; i++) {
                if (strcasecmp(rt->forms[i]->name, startupFormName) == 0) {
                    startup = rt->forms[i];
                    break;
                }
            }
        }

        basFormRtShowForm(rt, startup, false);
    }
}


void *basFormRtLoadForm(void *ctx, const char *formName) {
    BasFormRtT *rt = (BasFormRtT *)ctx;

    // Check if form already exists
    for (int32_t i = 0; i < (int32_t)arrlen(rt->forms); i++) {
        if (strcasecmp(rt->forms[i]->name, formName) == 0) {
            return rt->forms[i];
        }
    }

    // Check the .frm cache for reload after unload.  sLoadingFrm is set
    // only while basFormRtLoadFrm's frmParse is running: the parser
    // re-enters this function via frmLoadOnFormBegin, and that nested
    // call must fall through to bare-form creation (which the parse
    // then populates) instead of recursing into another basFormRtLoadFrm.
    // The flag is NOT set here -- basFormRtLoadFrm clears it before its
    // Resize/Load tail, so a 'Load Form2' issued from Form1_Load still
    // finds Form2's cached .frm.
    if (!sLoadingFrm) {
        for (int32_t i = 0; i < rt->frmCacheCount; i++) {
            if (strcasecmp(rt->frmCache[i].formName, formName) == 0) {
                // Parse straight from the cache entry -- it is NOT
                // consumed, so a parse failure leaves the form loadable
                // once the .frm is fixed.  The frmSource pointer is
                // malloc-stable even if nested loads realloc the cache
                // array, and basFormRtLoadFrm's re-cache block sees the
                // entry already present and skips it.
                return basFormRtLoadFrm(rt, rt->frmCache[i].frmSource, rt->frmCache[i].frmSourceLen);
            }
        }
    }

    // No cache entry: create a bare form (first-time load without .frm file)
    BasFormT *form = allocForm(rt, formName, false, true, BAS_DEFAULT_FORM_W, BAS_DEFAULT_FORM_H);

    if (!form) {
        return NULL;
    }

    // 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.
    basFormRtInitFormVars(rt, form, !sLoadingFrm);

    return form;
}


BasFormT *basFormRtLoadFrm(BasFormRtT *rt, const char *source, int32_t sourceLen) {
    if (!rt || !source || sourceLen <= 0) {
        return NULL;
    }

    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  = frmLoadOnFormBegin;
    cbs.onFormProp   = frmLoadOnFormProp;
    cbs.onMenuBegin  = frmLoadOnMenuBegin;
    cbs.onMenuEnd    = frmLoadOnMenuEnd;
    cbs.onMenuProp   = frmLoadOnMenuProp;
    cbs.onCtrlBegin  = frmLoadOnCtrlBegin;
    cbs.onCtrlEnd    = frmLoadOnCtrlEnd;
    cbs.onCtrlProp   = frmLoadOnCtrlProp;

    // Set the guard so the nested basFormRtLoadForm (fired from
    // frmLoadOnFormBegin) creates a bare form and skips its init code --
    // running it here would fire against an empty control list; init
    // runs below once the controls exist.  Save+restore so a nested
    // basFormRtLoadForm -> basFormRtLoadFrm reentry still terminates
    // the inner guard correctly.
    bool    savedLoadingFrm = sLoadingFrm;
    int32_t formsBefore     = (int32_t)arrlen(rt->forms);
    sLoadingFrm             = true;
    bool    parsed          = frmParse(source, sourceLen, &cbs);
    sLoadingFrm             = savedLoadingFrm;

    // A callback that raised a runtime error (unknown widget type) has
    // already halted the VM; treat the load as failed rather than firing
    // Resize/Load on a half-built form and forcing the VM back to running.
    if (!parsed || rt->terminated) {
        // Discard a half-built form created by THIS parse (nesting
        // overflow etc.), or it would stay findable in rt->forms with
        // missing controls.  Created-by-us means it sits past the
        // pre-parse count; a pre-existing form matched by name is left
        // alone.  Immediate free is safe even at eventDepth > 0: no VM
        // frame can reference a form whose Form_Load never fired.
        if (ctx.form) {
            bool created = false;

            for (int32_t i = formsBefore; i < (int32_t)arrlen(rt->forms); i++) {
                if (rt->forms[i] == ctx.form) {
                    created = true;
                    break;
                }
            }

            if (created) {
                ctx.form->unloading = true;
                detachFormForUnload(rt, ctx.form);
                unloadFormNow(rt, ctx.form);
            }
        }

        arrfree(ctx.menuItems);
        return NULL;
    }

    BasFormT        *form      = ctx.form;
    BasFrmMenuItemT *menuItems = ctx.menuItems;

    // Rule-N bracket around the ENTIRE post-parse tail: dvxFitWindow /
    // dvxResizeWindow below fire the BASIC Resize event synchronously,
    // basFormRtInitFormVars runs init code, and the Load fire runs user
    // code -- any of them can execute 'Unload Me'.  The bracket defers
    // the unload flush past every use of `form` in this function; the
    // `dying` capture happens before the closing rtEventLeave because
    // that leave may free the form.
    rtEventEnter(rt);

    // Apply accumulated form-level properties
    if (form) {
        // Ensure content box exists even if form has no controls.  contentBox
        // starts as root, so test for the default root box (not NULL) or a
        // form-level Layout would be ignored.
        if (form->contentBox == form->root && form->root) {
            form->contentBox = basFormRtCreateContentBox(form->root, form->frmLayout);
        }

        // Build menu bar + popup menus from accumulated menu items.
        // A top-level Menu with Visible=False becomes a standalone
        // popup (stored by name on the form).  All other top-level
        // menus go on the window's menu bar.  Nested items are added
        // to whichever top-level (bar or popup) is currently active.
        int32_t menuCount = (int32_t)arrlen(menuItems);

        if (menuCount > 0 && form->window) {
            MenuBarT *bar = NULL;
            bool      haveVisibleTop = false;

            for (int32_t i = 0; i < menuCount; i++) {
                if (menuItems[i].level == 0 && menuItems[i].visible) {
                    haveVisibleTop = true;
                    break;
                }
            }

            if (haveVisibleTop) {
                bar = wmAddMenuBar(form->window);
            }

            MenuT *menuStack[FRM_MAX_NESTING];
            memset(menuStack, 0, sizeof(menuStack));
            bool   topIsPopup = false;
            MenuT *curTopPopup = NULL;

            for (int32_t i = 0; i < menuCount; i++) {
                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) {
                    if (mi->visible) {
                        // Top-level menu header on the bar
                        menuStack[0] = bar ? wmAddMenu(bar, mi->caption) : NULL;
                        topIsPopup = false;
                        curTopPopup = NULL;
                    } else {
                        // Popup menu: allocate standalone, store by name.
                        curTopPopup = wmCreateMenu();
                        menuStack[0] = curTopPopup;
                        topIsPopup = true;

                        if (curTopPopup) {
                            BasFrmPopupMenuT entry;
                            memset(&entry, 0, sizeof(entry));
                            snprintf(entry.name, BAS_MAX_IDENT, "%s", mi->name);
                            entry.menu     = curTopPopup;
                            entry.ownsMenu = true;   // resource-built popup root
                            arrput(form->popupMenus, entry);
                            form->popupMenuCount = (int32_t)arrlen(form->popupMenus);
                        }
                    }
                } else if (isSep && menuStack[mi->level - 1]) {
                    wmAddMenuSeparator(menuStack[mi->level - 1]);
                } else if (isSubParent && menuStack[mi->level - 1]) {
                    menuStack[mi->level] = wmAddSubMenu(menuStack[mi->level - 1], mi->caption);
                } else if (menuStack[mi->level - 1]) {
                    int32_t id = MENU_ID_BASE + i;

                    if (mi->radioCheck) {
                        wmAddMenuRadioItem(menuStack[mi->level - 1], mi->caption, id, mi->checked);
                    } else if (mi->checked) {
                        wmAddMenuCheckItem(menuStack[mi->level - 1], mi->caption, id, true);
                    } else {
                        wmAddMenuItem(menuStack[mi->level - 1], mi->caption, id);
                    }

                    if (!mi->enabled && bar && !topIsPopup) {
                        wmMenuItemSetEnabled(bar, id, false);
                    }

                    // Store ID-to-name mapping for event dispatch.
                    // Popup menu items use the same table -- the
                    // window's onMenu handler looks up by ID
                    // regardless of which menu the click came from.
                    BasMenuIdMapT map;
                    memset(&map, 0, sizeof(map));
                    map.id = id;
                    snprintf(map.name, BAS_MAX_IDENT, "%s", mi->name);
                    arrput(form->menuIdMap, map);
                    form->menuIdMapCount = (int32_t)arrlen(form->menuIdMap);
                }
            }

            form->window->onMenu = onFormMenu;
        }

        arrfree(menuItems);
        menuItems = NULL;

        // Call Resize on widgets that need it (e.g. PictureBox) so the
        // internal bitmap matches the layout size. Width/Height aren't
        // known until properties are parsed, so this must happen after
        // loading but before dvxFitWindow.
        for (int32_t i = 0; i < (int32_t)arrlen(form->controls); i++) {
            resizeIfaceBuffer(form->controls[i]);
        }

        // Apply form properties after Resize calls so that
        // PictureBox bitmaps are resized before dvxFitWindow.
        if (form->frmHasResizable) {
            form->window->resizable = form->frmResizable;
        }

        if (form->frmAutoSize) {
            dvxFitWindow(rt->ctx, form->window);
        } else if (form->frmWidth > 0 && form->frmHeight > 0) {
            dvxResizeWindow(rt->ctx, form->window, form->frmWidth, form->frmHeight);
        }

        if (form->frmCentered) {
            form->window->x = (rt->ctx->display.width - form->window->w) / 2;
            form->window->y = (rt->ctx->display.height - form->window->h) / 2;
        } else if (form->frmLeft > 0 || form->frmTop > 0) {
            form->window->x = form->frmLeft;
            form->window->y = form->frmTop;
        }
    }

    // Cache the .frm source for reload after unload
    if (form) {
        bool cached = false;

        for (int32_t i = 0; i < rt->frmCacheCount; i++) {
            if (strcasecmp(rt->frmCache[i].formName, form->name) == 0) {
                cached = true;
                break;
            }
        }

        if (!cached) {
            BasFrmCacheT entry;
            memset(&entry, 0, sizeof(entry));
            snprintf(entry.formName, BAS_MAX_IDENT, "%s", form->name);
            entry.frmSource    = (char *)malloc(sourceLen + 1);
            entry.frmSourceLen = sourceLen;

            if (entry.frmSource) {
                memcpy(entry.frmSource, source, sourceLen);
                entry.frmSource[sourceLen] = '\0';
            }

            arrput(rt->frmCache, entry);
            rt->frmCacheCount = (int32_t)arrlen(rt->frmCache);
        }
    }

    // Allocate per-form variable storage and run init code.
    // This must happen AFTER controls are created (so init code can
    // reference controls by name) but BEFORE Form_Load fires.  Skipped
    // when a Resize handler already unloaded the form -- its Unload has
    // fired, so no further code may run on it.
    if (form && !form->unloading) {
        basFormRtInitFormVars(rt, form, true);
    }

    // Fire the Load event now that the form and controls are ready
    if (form && !form->unloading) {
        basFormRtFireEvent(rt, form, form->name, BAS_EVT_LOAD);
    }

    // Auto-refresh Data controls that have no MasterSource (masters/standalone).
    // Detail controls are refreshed automatically by the master-detail cascade
    // when the master's Reposition event fires.  Skipped when a handler
    // unloaded the form (e.g. 'Unload Me' inside Form_Load).
    if (form && !form->unloading) {
        for (int32_t i = 0; i < (int32_t)arrlen(form->controls); i++) {
            BasControlT *dc = form->controls[i];

            if (strcasecmp(dc->typeName, "Data") != 0 || !dc->widget) {
                continue;
            }

            // Skip details -- they'll be refreshed by the cascade
            const char *ms = ifaceGetStringProp(dc, "MasterSource");

            if (ms && ms[0]) {
                continue;
            }

            // wgtDataCtrlRefresh now fires onChange (updateBoundControls +
            // Reposition + refreshDetailControls) itself, so the manual
            // follow-up calls would double-run them.
            wgtDataCtrlRefresh(dc->widget);
        }
    }

    // Capture the unload state BEFORE the bracket closes: the leave
    // below flushes the deferred unload, freeing the form.  A caller
    // must receive NULL, never a dangling pointer.
    bool dying = form && form->unloading;

    rtEventLeave(rt);

    return dying ? NULL : form;
}


int32_t basFormRtMsgBox(void *ctx, const char *message, int32_t flags, const char *title) {
    BasFormRtT *rt = (BasFormRtT *)ctx;

    return dvxMessageBox(rt->ctx, (title && title[0]) ? title : "DVX BASIC", message, flags);
}


void basFormRtRegisterFrm(BasFormRtT *rt, const char *formName, const char *source, int32_t sourceLen) {
    if (!rt || !formName || !source || sourceLen <= 0) {
        return;
    }

    BasFrmCacheT entry;
    snprintf(entry.formName, BAS_MAX_IDENT, "%s", formName);
    entry.frmSource = (char *)malloc(sourceLen + 1);

    if (!entry.frmSource) {
        return;
    }

    entry.frmSourceLen = sourceLen;
    memcpy(entry.frmSource, source, sourceLen);
    entry.frmSource[sourceLen] = '\0';
    arrput(rt->frmCache, entry);
    rt->frmCacheCount = (int32_t)arrlen(rt->frmCache);
}


void basFormRtRemoveCtrl(void *ctx, void *formRef, const char *ctrlName) {
    BasFormRtT *rt   = (BasFormRtT *)ctx;
    BasFormT   *form = (BasFormT *)formRef;

    if (!rt || !form || !ctrlName) {
        return;
    }

    BasControlT *ctrl = findCtrlInForm(form, ctrlName);

    if (ctrl) {
        removeCtrlTree(rt, form, ctrl);
    }
}


void basFormRtRunSimple(BasFormRtT *rt) {
    if (!rt || !rt->vm) {
        return;
    }

    BasVmT *vm = rt->vm;
    basVmSetStepLimit(vm, BAS_VM_DEFAULT_STEP_SLICE);

    BasVmResultE result;

    do {
        result = basVmRun(vm);

        if (result == BAS_VM_STEP_LIMIT) {
            if (!dvxUpdate(rt->ctx)) {
                break;
            }
        } else if (result == BAS_VM_ERROR) {
            // basFormRtRuntimeError already surfaces its own error
            // dialog and sets rt->terminated, so only show this generic
            // box for VM-internal errors (division by zero, type
            // mismatch, etc.) that didn't come through that path.
            if (!rt->terminated) {
                const char *errMsg = basVmGetError(vm);
                char buf[BAS_ERR_BOX_LEN];
                snprintf(buf, sizeof(buf), "Runtime error:\n%s", errMsg ? errMsg : "Unknown error");
                dvxMessageBox(rt->ctx, "Error", buf, 0);
            }
            break;
        } else {
            break;
        }
    } while (1);

    // VB-style event loop: keep alive while forms are open
    if (result == BAS_VM_HALTED) {
        basFormRtEventLoop(rt);
    }
}


// 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, ...) {
    // If we're already in a terminated state, swallow follow-on errors
    // silently.  The modal message box pumps events, which can re-enter
    // the form runtime and trip the same missing-control / missing-
    // method path for every queued event.  Showing the dialog again
    // for each one stacks modals and spams the log; the first report
    // is enough to point the user at the root cause.
    if (rt && rt->terminated) {
        return;
    }

    char    details[BAS_ERR_DETAIL_LEN];
    va_list ap;

    va_start(ap, detailFmt);
    vsnprintf(details, sizeof(details), detailFmt, ap);
    va_end(ap);

    // Log the error only when we're also going to show the modal
    // dialog.  Hosts that suppress the dialog (the IDE) do their own
    // reporting via the Output pane and editor navigation, and don't
    // need a redundant log copy of every runtime error.
    if (!rt || !rt->suppressErrorDialog) {
        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[BAS_ERR_DETAIL_LEN];

            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;
        }
    }

    // Halt the VM and flip rt->terminated BEFORE showing the dialog.
    // dvxMessageBox pumps events while it's open, and a single missing
    // control typically has several queued events targeting it (load +
    // activate + paint + ...).  If we haven't marked the runtime as
    // terminated yet, each re-entry into basFormRtRuntimeError passes
    // the early-return check and stacks another dialog.  Marking first
    // means the re-entry sees terminated=true, returns early, and the
    // user only has to dismiss one dialog.  Setting vm->errorMsg makes
    // basVmRun return BAS_VM_ERROR (not HALTED) so the IDE treats this
    // as a runtime error, shows the diagnostic, and navigates the
    // editor to vm->currentLine.  ended=true also breaks the IDE's
    // VB-style event pump loop.
    if (rt && rt->vm) {
        BasVmT *vm = rt->vm;

        // Format: "Summary\nDetails".  No embedded "on line N:" so the
        // host (IDE) can prefix its own proc/line header without
        // having to parse the line number back out of the middle.
        // For the compiled stub, the host shows this as-is, which is
        // fine -- the details string is the interesting part.
        snprintf(vm->errorMsg, sizeof(vm->errorMsg),
                 "%s\n%s",
                 summary ? summary : "Runtime error", details);

        // Snapshot PC and source line at the error site so the
        // debugger can locate the owning SUB and line.  runSubLoop
        // restores vm->pc to the caller's saved PC on the way out (to
        // keep RESUME semantics clean for ON ERROR), and
        // vm->currentLine keeps being stomped by OP_LINE as other
        // queued event handlers run.  Both would otherwise be wrong
        // by the time the IDE reads them after the loop exits.
        //
        // Prefer vm->currentOpPc over vm->pc: basVmStep advances
        // vm->pc past the opcode AND its operands before dispatching
        // the handler, so by the time we get here it can point at
        // the first byte of the NEXT proc, which makes the proc
        // lookup land on the wrong handler.  currentOpPc is the PC
        // of the opcode that triggered the host callback.
        vm->errorPc   = vm->currentOpPc > 0 ? vm->currentOpPc : vm->pc;
        vm->errorLine = vm->currentLine;

        vm->running = false;
        vm->ended   = true;
    }

    if (rt) {
        rt->terminated = true;
    }

    if (rt && rt->ctx && !rt->suppressErrorDialog) {
        char boxMsg[BAS_ERR_BOX_LEN];
        snprintf(boxMsg, sizeof(boxMsg), "%s\n\n%s",
                 summary ? summary : "Runtime error",
                 details);
        dvxMessageBox(rt->ctx, "BASIC Runtime Error", boxMsg, MB_OK | MB_ICONERROR);
    }
}


// Release all serial/secLink resources and unregister both idle pollers from
// the shell.  Safe to call repeatedly.  The pollers live in this shared
// runtime DXE but iterate per-app slots/terminals, so they MUST be dropped
// when a BASIC app goes away -- on normal exit via basFormRtDestroy, and on
// force-kill (where basFormRtDestroy never runs) via the stub's _appShutdown.
// NOTE: closes every open connection/port, which is correct for the single
// foreground BASIC app; concurrent BASIC instances sharing these module-scope
// slots is a separate, pre-existing limitation.
void basFormRtSerialShutdown(void) {
    for (int32_t i = 0; i < (int32_t)arrlen(sCommSlots); i++) {
        if (sCommSlots[i]) {
            CommClose(i + 1);
        }
    }

    arrfree(sCommSlots);
    sCommSlots = NULL;

    if (sSerApiResolved && sSerApi.close) {
        for (int32_t i = 0; i < RS232_NUM_PORTS; i++) {
            if (sSerAttach[i].attached) {
                SerClose(i + 1);
            }
        }
    }

    if (sShellUnregisterIdle) {
        sShellUnregisterIdle(commIdlePoll, NULL);
        sShellUnregisterIdle(serIdlePoll, NULL);
    }
}


void basFormRtSetEvent(void *ctx, void *ctrlRef, const char *eventName, const char *handlerName) {
    (void)ctx;
    BasControlT *ctrl = (BasControlT *)ctrlRef;

    if (!ctrl || !eventName || !handlerName) {
        return;
    }

    // Check for existing override on this event and update it
    for (int32_t i = 0; i < ctrl->eventOverrideCount; i++) {
        if (strcasecmp(ctrl->eventOverrides[i].eventName, eventName) == 0) {
            snprintf(ctrl->eventOverrides[i].handlerName, BAS_MAX_IDENT, "%s", handlerName);
            return;
        }
    }

    // Add new override
    if (ctrl->eventOverrideCount < BAS_MAX_EVENT_OVERRIDES) {
        BasEventOverrideT *ov = &ctrl->eventOverrides[ctrl->eventOverrideCount++];
        snprintf(ov->eventName, BAS_MAX_IDENT, "%s", eventName);
        snprintf(ov->handlerName, BAS_MAX_IDENT, "%s", handlerName);
    }
}


void basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT value) {
    BasFormRtT *rt = (BasFormRtT *)ctx;
    BasControlT *ctrl = (BasControlT *)ctrlRef;

    if (!ctrl) {
        basFormRtRuntimeError(rt,
            "Assignment to unknown control",
            "Control: %s\nProperty: %s\nControl was not found on any loaded form.",
            (rt && rt->lastLookupName[0]) ? rt->lastLookupName : "?",
            propName ? propName : "?");
        return;
    }

    // Menu item properties.  Bar items route through wmMenuItemSetChecked so
    // the menu-bar radio-group walk runs; popup-only items route through
    // wmMenuItemSetCheckedInMenu, which does the same walk on a standalone
    // menu.  Enabled writes the item's flag directly in both cases.
    if (ctrl->menuId > 0 && ctrl->form) {
        bool       fromBar = false;
        MenuItemT *item    = resolveMenuItem(ctrl->form, ctrl->menuId, &fromBar);

        if (item) {
            if (strcasecmp(propName, "Checked") == 0) {
                if (fromBar) {
                    MenuBarT *bar = (ctrl->form->window) ? ctrl->form->window->menuBar : NULL;
                    wmMenuItemSetChecked(bar, ctrl->menuId, basValIsTruthy(value));
                } else {
                    for (int32_t i = 0; i < ctrl->form->popupMenuCount; i++) {
                        wmMenuItemSetCheckedInMenu(ctrl->form->popupMenus[i].menu, ctrl->menuId, basValIsTruthy(value));
                    }
                }

                return;
            }

            if (strcasecmp(propName, "Enabled") == 0) {
                item->enabled = basValIsTruthy(value);
                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.",
            ctrlDisplayName(ctrl), propName ? propName : "?");
        return;
    }

    // Form-level property assignment uses the window and BasFormT
    if (ctrl->form && ctrl == &ctrl->form->formCtrl) {
        setFormProp(rt, ctrl->form, propName, value);
        return;
    }

    // Widget-declared properties first, then the common set (see
    // basFormRtGetProp for the override rationale).
    if (ctrl->iface && setIfaceProp(ctrl->iface, ctrl->widget, propName, value)) {
        return;
    }

    if (setCommonProp(ctrl, propName, value)) {
        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 : "?");
}


void basFormRtShowForm(void *ctx, void *formRef, bool modal) {
    BasFormRtT *rt   = (BasFormRtT *)ctx;
    BasFormT   *form = (BasFormT *)formRef;

    if (!form) {
        basFormRtRuntimeError(rt,
            "Show on unknown form",
            "The form reference resolved to NULL; check that the form name exists.");
        return;
    }

    // A detached form is committed to unload (its Unload already fired,
    // only the frees are pending) -- it must not be resurrected.
    if (form->detached) {
        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;
    }

    // Rule-N bracket: dvxShowWindow/dvxRaiseWindow fire Activate and
    // Deactivate, and dvxFitWindow fires Resize; a handler may 'Unload
    // Me' this form.  The bracket keeps the trailing reads below on live
    // memory, and the unloading re-check stops the modal assignment from
    // re-arming the input gate on a window the unload just hid.
    rtEventEnter(rt);

    dvxShowWindow(rt->ctx, form->window);
    dvxRaiseWindow(rt->ctx, form->window);

    if (!form->unloading) {
        if (form->frmAutoSize) {
            dvxFitWindow(rt->ctx, form->window);
        }

        if (modal) {
            rt->ctx->modalWindow = form->window;
        }
    }

    rtEventLeave(rt);
}


// Free ALL per-form heap resources: form variables, controls (with
// tooltips), menuIdMap proxies + array, and popup menus.  Does NOT
// destroy the window, does NOT remove the form from rt->forms, and does
// NOT free the BasFormT itself -- callers differ on those.  Single
// source of truth for the three formerly-diverged teardown sites
// (basFormRtDestroy, basFormRtUnloadForm, onFormClose).
static void basFormRtTeardownForm(BasFormRtT *rt, BasFormT *form) {
    (void)rt;

    // Detach serial/secLink terminals bound to this form's widgets while
    // both the controls array and the widgets are still alive.  Skipping
    // this leaves sSerAttach[]/sCommSlots[] pointing at freed widgets.
    formRtDetachTermsForForm(form);

    if (form->formVars) {
        for (int32_t i = 0; i < form->formVarCount; i++) {
            basValRelease(&form->formVars[i]);
        }

        free(form->formVars);
        form->formVars     = NULL;
        form->formVarCount = 0;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(form->controls); i++) {
        freeControl(form->controls[i]);
    }

    arrfree(form->controls);
    form->controls = NULL;

    for (int32_t i = 0; i < form->menuIdMapCount; i++) {
        freeControl(form->menuIdMap[i].proxy);
    }

    arrfree(form->menuIdMap);
    form->menuIdMap      = NULL;
    form->menuIdMapCount = 0;

    // Free popup menus with first-occurrence ownership (mirrors the
    // DestroyMenu isSubmenu dedupe).  AddSubMenu requires its parent to
    // already be registered, so a given MenuT* always appears first as
    // its owning root; later duplicate submenu entries are skipped to
    // avoid the double-free that wmFreeMenu's recursion would cause.
    // Free only the entries flagged as owning roots.  AddSubMenu stores a
    // non-owning alias (ownsMenu=false) whose MenuT is freed by its root's
    // wmFreeMenu recursion; the old exact-pointer dedupe never matched those
    // distinct child pointers and double-freed them.
    for (int32_t i = 0; i < form->popupMenuCount; i++) {
        if (form->popupMenus[i].ownsMenu) {
            wmFreeMenu(form->popupMenus[i].menu);
        }
    }

    arrfree(form->popupMenus);
    form->popupMenus     = NULL;
    form->popupMenuCount = 0;
}


void basFormRtUnloadForm(void *ctx, void *formRef) {
    BasFormRtT *rt   = (BasFormRtT *)ctx;
    BasFormT   *form = (BasFormT *)formRef;

    if (!form || form->unloading) {
        return;
    }

    // Block re-entrant Unload of this form from its own QueryUnload /
    // Unload handlers; cleared below if QueryUnload cancels.
    form->unloading = true;

    // QueryUnload: give the form a chance to cancel
    if (basFormRtFireEventWithCancel(rt, form, form->name, BAS_EVT_QUERYUNLOAD)) {
        form->unloading = false;
        return;
    }

    basFormRtFireEvent(rt, form, form->name, BAS_EVT_UNLOAD);

    // Commit point: the form becomes unfindable, invisible, and
    // event-inert right now, on both the deferred and immediate paths.
    detachFormForUnload(rt, form);

    // If a VM event handler (or a native bridge still holding form or
    // control pointers) is on the stack, the handler's form variables
    // and the firing BasControlT live on this form -- freeing them now
    // is a guaranteed use-after-free when the frame resumes.  Defer the
    // frees to rtEventLeave; the queue is a dynamic array with no cap.
    if (rt->eventDepth > 0) {
        arrput(rt->pendingUnload, form);
        return;
    }

    unloadFormNow(rt, form);
}


const char *basInputBox2(const char *title, const char *prompt, const char *defaultText) {
    if (!sFormRt) {
        sLastInputBoxCancelled = true;
        return "";
    }

    static char buf[BAS_INPUTBOX_BUF_LEN];
    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;
    }

    int32_t result = defaultVal;
    dvxIntInputBox(sFormRt->ctx, title, prompt, defaultVal, minVal, maxVal, 1, &result);
    return result;
}


static const BasProcEntryT *basModuleFindProc(const BasModuleT *mod, const char *name) {
    if (!mod || !mod->procs || !name) {
        return NULL;
    }

    for (int32_t i = 0; i < mod->procCount; i++) {
        if (strcasecmp(mod->procs[i].name, name) == 0) {
            return &mod->procs[i];
        }
    }

    return NULL;
}


// Push the already-marshalled cdecl argument words and call a native
// function via funcPtr.  Returns the EAX result word; when fpReturn is
// true the x87 ST(0) result is also stored through fpResult via fstpl.
//
// The ESP save, the entire push loop, the call, and the ESP restore
// live inside ONE __asm__ block per return path: a saved-ESP value held
// in a scratch register cannot survive across separate asm statements
// (GCC is free to reuse the register), so everything that depends on it
// must stay in the same block.  EDI holds the saved ESP, ESI the args
// base, ECX the down-counter; all are clobber-listed so GCC reloads
// anything it kept there.  "memory" stops GCC caching the string args
// the callee may dereference.  fstpl is gated strictly to fpReturn so
// the integer path never pops the x87 stack.
static uint32_t basNativeCall(void *funcPtr, const uint32_t *nativeArgs, int32_t nativeCount, bool fpReturn, double *fpResult) {
    uint32_t rawResult = 0;

    if (fpReturn) {
        __asm__ __volatile__(
            "movl %%esp, %%edi\n\t"          // save ESP
            "movl %[cnt], %%ecx\n\t"         // ECX = arg count
            "1:\n\t"
            "testl %%ecx, %%ecx\n\t"
            "jle 2f\n\t"
            "decl %%ecx\n\t"
            "pushl (%%esi, %%ecx, 4)\n\t"    // push nativeArgs[ECX]
            "jmp 1b\n\t"
            "2:\n\t"
            "call *%[fn]\n\t"
            "fstpl %[fpres]\n\t"             // pop x87 ST(0) -> *fpResult
            "movl %%edi, %%esp\n\t"          // restore ESP
            : "=a"(rawResult), [fpres] "=m"(*fpResult)
            : [fn] "r"(funcPtr), "S"(nativeArgs), [cnt] "r"(nativeCount)
            : "ecx", "edi", "edx", "cc", "memory"
        );
    } else {
        __asm__ __volatile__(
            "movl %%esp, %%edi\n\t"          // save ESP
            "movl %[cnt], %%ecx\n\t"         // ECX = arg count
            "1:\n\t"
            "testl %%ecx, %%ecx\n\t"
            "jle 2f\n\t"
            "decl %%ecx\n\t"
            "pushl (%%esi, %%ecx, 4)\n\t"    // push nativeArgs[ECX]
            "jmp 1b\n\t"
            "2:\n\t"
            "call *%[fn]\n\t"
            "movl %%edi, %%esp\n\t"          // restore ESP
            : "=a"(rawResult)
            : [fn] "r"(funcPtr), "S"(nativeArgs), [cnt] "r"(nativeCount)
            : "ecx", "edi", "edx", "cc", "memory"
        );
    }

    return rawResult;
}


int32_t basPromptSave(const char *title) {
    if (!sFormRt) {
        return DVX_SAVE_NO;
    }

    return dvxPromptSave(sFormRt->ctx, title);
}


static BasValueT callCommonMethod(BasControlT *ctrl, const char *methodName, BasValueT *args, int32_t argc) {
    int32_t methodId = basFormRtCommonMethodId(methodName);

    if (methodId == BAS_CM_SETFOCUS) {
        wgtSetFocused(ctrl->widget);
        return zeroValue();
    }

    if (methodId == BAS_CM_REFRESH) {
        wgtInvalidatePaint(ctrl->widget);
        return zeroValue();
    }

    if (methodId == BAS_CM_SETREADONLY) {
        bool ro = (argc >= 1) ? (basValToNumber(args[0]) != 0.0) : true;
        wgtSetReadOnly(ctrl->widget, ro);
        return zeroValue();
    }

    if (methodId == BAS_CM_SETENABLED) {
        bool en = (argc >= 1) ? (basValToNumber(args[0]) != 0.0) : true;
        wgtSetEnabled(ctrl->widget, en);
        return zeroValue();
    }

    if (methodId == BAS_CM_SETVISIBLE) {
        bool vis = (argc >= 1) ? (basValToNumber(args[0]) != 0.0) : true;
        wgtSetVisible(ctrl->widget, vis);
        return zeroValue();
    }

    // ========================================================
    // Popup / context menu methods
    // ========================================================
    //
    // Any control (and the synthetic form control) can show, build,
    // or tear down popup menus owned by its form.  Menus are named
    // by string and stored on the form; IDs are shared with the
    // menu-bar items so nameClick handlers fire regardless of which
    // surface the user picked from.

    // ctrl and ctrl->widget are guaranteed non-NULL by basFormRtCallMethod.
    BasFormT *menuForm = ctrl->form;

    if (methodId == BAS_CM_POPUPMENU) {
        // PopupMenu name$ [, x%, y%]
        if (!menuForm || !menuForm->window || argc < 1) {
            return zeroValue();
        }

        BasStringT *s = basValFormatString(args[0]);
        BasFrmPopupMenuT *pm = findPopupMenu(menuForm, s->data);
        basStringUnref(s);

        if (!pm || !pm->menu) {
            return zeroValue();
        }

        int32_t screenX = sFormRt ? sFormRt->ctx->mouseX : 0;
        int32_t screenY = sFormRt ? sFormRt->ctx->mouseY : 0;

        if (argc >= 3) {
            // Explicit coords are relative to the control.  WidgetT x/y
            // are window-content-relative (the form's root widget sits at
            // 0,0), and dvxShowContextMenu wants screen coords, so add
            // the window origin and its content offset.
            WindowT *win = menuForm->window;
            int32_t  x   = (int32_t)basValToNumber(args[1]);
            int32_t  y   = (int32_t)basValToNumber(args[2]);

            screenX = win->x + win->contentX + ctrl->widget->x + x;
            screenY = win->y + win->contentY + ctrl->widget->y + y;
        }

        if (sFormRt && sFormRt->ctx) {
            dvxShowContextMenu(sFormRt->ctx, menuForm->window, pm->menu, screenX, screenY);
        }

        return zeroValue();
    }

    if (methodId == BAS_CM_CREATEMENU) {
        // CreateMenu name$ -- allocate an empty named popup menu.
        // No-op if a menu with that name already exists.
        if (!menuForm || argc < 1) {
            return zeroValue();
        }

        BasStringT *s = basValFormatString(args[0]);

        if (!findPopupMenu(menuForm, s->data)) {
            BasFrmPopupMenuT entry;
            memset(&entry, 0, sizeof(entry));
            snprintf(entry.name, BAS_MAX_IDENT, "%s", s->data);
            entry.menu     = wmCreateMenu();
            entry.ownsMenu = true;   // CreateMenu root
            arrput(menuForm->popupMenus, entry);
            menuForm->popupMenuCount = (int32_t)arrlen(menuForm->popupMenus);
        }

        basStringUnref(s);
        return zeroValue();
    }

    if (methodId == BAS_CM_ADDMENUITEM) {
        // AddMenuItem parentMenu$, itemName$, caption$
        if (!menuForm || argc < 3) {
            return zeroValue();
        }

        BasStringT *parent  = basValFormatString(args[0]);
        BasStringT *itemN   = basValFormatString(args[1]);
        BasStringT *capN    = basValFormatString(args[2]);

        BasFrmPopupMenuT *pm = findPopupMenu(menuForm, parent->data);

        if (pm && pm->menu) {
            int32_t id = nextMenuItemId(menuForm);
            wmAddMenuItem(pm->menu, capN->data, id);

            BasMenuIdMapT map;
            memset(&map, 0, sizeof(map));
            map.id = id;
            snprintf(map.name, BAS_MAX_IDENT, "%s", itemN->data);
            arrput(menuForm->menuIdMap, map);
            menuForm->menuIdMapCount = (int32_t)arrlen(menuForm->menuIdMap);

            if (menuForm->window) {
                menuForm->window->onMenu = onFormMenu;
            }
        }

        basStringUnref(parent);
        basStringUnref(itemN);
        basStringUnref(capN);
        return zeroValue();
    }

    if (methodId == BAS_CM_ADDMENUSEPARATOR) {
        // AddMenuSeparator parentMenu$
        if (!menuForm || argc < 1) {
            return zeroValue();
        }

        BasStringT *parent = basValFormatString(args[0]);
        BasFrmPopupMenuT *pm = findPopupMenu(menuForm, parent->data);

        if (pm && pm->menu) {
            wmAddMenuSeparator(pm->menu);
        }

        basStringUnref(parent);
        return zeroValue();
    }

    if (methodId == BAS_CM_ADDSUBMENU) {
        // AddSubMenu parentMenu$, childName$, caption$
        // The new submenu can have AddMenuItem etc. called on it
        // via `childName`.
        if (!menuForm || argc < 3) {
            return zeroValue();
        }

        BasStringT *parent = basValFormatString(args[0]);
        BasStringT *childN = basValFormatString(args[1]);
        BasStringT *capN   = basValFormatString(args[2]);

        BasFrmPopupMenuT *parentPm = findPopupMenu(menuForm, parent->data);

        if (parentPm && parentPm->menu && !findPopupMenu(menuForm, childN->data)) {
            MenuT *sub = wmAddSubMenu(parentPm->menu, capN->data);

            if (sub) {
                BasFrmPopupMenuT entry;
                memset(&entry, 0, sizeof(entry));
                snprintf(entry.name, BAS_MAX_IDENT, "%s", childN->data);
                // NB: sub is owned by parentPm->menu (wmFreeMenu
                // recurses); we store a non-owning reference here so
                // AddMenuItem etc. can look it up by name.  Don't
                // wmFreeMenu the child on DestroyMenu for this
                // name; only the root popup owns the tree.
                entry.menu = sub;
                arrput(menuForm->popupMenus, entry);
                menuForm->popupMenuCount = (int32_t)arrlen(menuForm->popupMenus);
            }
        }

        basStringUnref(parent);
        basStringUnref(childN);
        basStringUnref(capN);
        return zeroValue();
    }

    // ========================================================
    // Toolbar runtime methods
    // ========================================================
    //
    // Toolbars hold buttons, separators, and other children laid
    // out horizontally.  These convenience methods create the new
    // widget, register it on the form by name (so BASIC code can
    // do tbNew.Enabled = False afterwards), and tuck it into the
    // toolbar.  Equivalent manual construction uses
    // CreateControl(form, "ImageButton", "tbNew", toolbar) -- these
    // just shorten the common case.

    bool isToolbar = (strcasecmp(ctrl->typeName, "Toolbar") == 0);

    if (isToolbar && methodId == BAS_CM_ADDBUTTON) {
        // AddButton name$, iconPath$
        //
        // Routed entirely through the generic runtime-creation +
        // property-set path so formrt doesn't link against the
        // ImageButton widget directly.  CreateControl("ImageButton")
        // resolves the widget via wgtFindByBasName at call time, and
        // setProp "Picture" dispatches to whichever setter the
        // ImageButton DXE registered in its iface.
        if (!menuForm || argc < 2) {
            return zeroValue();
        }

        BasStringT *nameS = basValFormatString(args[0]);
        BasStringT *pathS = basValFormatString(args[1]);

        BasControlT *btn = (BasControlT *)basFormRtCreateCtrlEx(sFormRt, menuForm, "ImageButton", nameS->data, ctrl);

        if (btn) {
            BasValueT v = basValString(pathS);
            basFormRtSetProp(sFormRt, btn, "Picture", v);
            basValRelease(&v);
        }

        basStringUnref(nameS);
        basStringUnref(pathS);
        return zeroValue();
    }

    if (isToolbar && methodId == BAS_CM_ADDTEXTBUTTON) {
        // AddTextButton name$, caption$
        if (!menuForm || argc < 2) {
            return zeroValue();
        }

        BasStringT *nameS = basValFormatString(args[0]);
        BasStringT *capS  = basValFormatString(args[1]);

        BasControlT *btn = (BasControlT *)basFormRtCreateCtrlEx(sFormRt, menuForm, "CommandButton", nameS->data, ctrl);

        if (btn) {
            BasValueT v = basValString(capS);
            basFormRtSetProp(sFormRt, btn, "Caption", v);
            basValRelease(&v);
        }

        basStringUnref(nameS);
        basStringUnref(capS);
        return zeroValue();
    }

    if (isToolbar && methodId == BAS_CM_ADDSEPARATOR) {
        // "Line" resolves to the separator widget via the iface
        // registry.  The separator widget auto-orients to its parent
        // (vertical inside a horizontal container, horizontal inside
        // a vertical one), so we don't need a distinct VLine basName.
        if (menuForm) {
            basFormRtCreateCtrlEx(sFormRt, menuForm, "Line", "", ctrl);
            wgtInvalidate(ctrl->widget);
        }

        return zeroValue();
    }

    if (isToolbar && methodId == BAS_CM_CLEAR) {
        // Destroy every child through the same path as RemoveControl so
        // each child's BasControlT (and any grandchild entries, terminal
        // bindings, tooltips) is detached now and freed only once no
        // handler -- possibly the clicked button's own -- is on the stack.
        if (!menuForm) {
            return zeroValue();
        }

        WidgetT *child = ctrl->widget->firstChild;

        while (child) {
            WidgetT     *next      = child->nextSibling;
            BasControlT *childCtrl = findCtrlByWidget(menuForm, child);

            if (childCtrl) {
                removeCtrlTree(sFormRt, menuForm, childCtrl);
            } else {
                wgtDestroy(child);
            }

            child = next;
        }

        wgtInvalidate(ctrl->widget);
        return zeroValue();
    }

    if (isToolbar && methodId == BAS_CM_BUTTONCOUNT) {
        int32_t count = 0;

        for (WidgetT *c = ctrl->widget->firstChild; c; c = c->nextSibling) {
            count++;
        }

        return basValLong(count);
    }

    if (methodId == BAS_CM_DESTROYMENU) {
        // DestroyMenu name$ -- free a top-level popup menu and
        // remove its entry.  Submenus are freed recursively as
        // part of their owning parent; calling DestroyMenu on a
        // submenu name only removes the lookup entry.
        if (!menuForm || argc < 1) {
            return zeroValue();
        }

        BasStringT *s = basValFormatString(args[0]);
        int32_t foundIdx = -1;

        for (int32_t i = 0; i < menuForm->popupMenuCount; i++) {
            if (strcasecmp(menuForm->popupMenus[i].name, s->data) == 0) {
                foundIdx = i;
                break;
            }
        }

        if (foundIdx >= 0) {
            MenuT *m = menuForm->popupMenus[foundIdx].menu;

            if (menuForm->popupMenus[foundIdx].ownsMenu) {
                // Freeing an owning root: first null every widget/window
                // contextMenu that points anywhere into this tree, then drop
                // ALL popupMenus entries (root + non-owning child aliases)
                // that reference it, then free the tree once.  Otherwise those
                // aliases and contextMenu pointers would dangle.
                for (int32_t i = 0; i < (int32_t)arrlen(menuForm->controls); i++) {
                    WidgetT *cw = menuForm->controls[i]->widget;

                    if (cw && menuContainsMenu(m, cw->contextMenu)) {
                        cw->contextMenu = NULL;
                    }
                }

                if (menuForm->window && menuContainsMenu(m, menuForm->window->contextMenu)) {
                    menuForm->window->contextMenu = NULL;
                }

                for (int32_t i = menuForm->popupMenuCount - 1; i >= 0; i--) {
                    if (menuContainsMenu(m, menuForm->popupMenus[i].menu)) {
                        arrdel(menuForm->popupMenus, i);
                    }
                }

                wmFreeMenu(m);
            } else {
                // Non-owning alias: drop just this entry; the root owns the
                // tree and will free it.
                arrdel(menuForm->popupMenus, foundIdx);
            }

            menuForm->popupMenuCount = (int32_t)arrlen(menuForm->popupMenus);
        }

        basStringUnref(s);
        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.
    basFormRtRuntimeError(sFormRt,
        "Method not found on control",
        "Method: %s\nControl: %s\nThe control has no method by that name.",
        methodName, ctrlDisplayName(ctrl));

    return zeroValue();
}


void CommAttach(int32_t handle, const char *termCtrlName, int32_t channel, int32_t encrypt) {
    CommSlotT *slot = commGetSlot(handle);

    if (!slot || !sFormRt || !termCtrlName) {
        return;
    }

    // Find the terminal widget by control name on any loaded form
    WidgetT *termWidget = findCtrlWidgetByName(termCtrlName);

    if (!termWidget) {
        return;
    }

    // commTermRead/commTermWrite index channels[] by termChannel; an
    // out-of-range channel would read/write past the slot.
    if (channel < 0 || channel >= SECLINK_NUM_CHANNELS) {
        return;
    }

    // One binding per terminal and per connection: drop whatever this
    // terminal was draining before, and unbind the terminal this slot
    // previously fed, or two AnsiTerms end up draining the same link.
    if (formRtDetachTermsForWidget(termWidget)) {
        formRtSerIdleSync();
    }

    if (slot->attachedTerm) {
        wgtAnsiTermSetComm(slot->attachedTerm, NULL, NULL, NULL);
    }

    slot->termChannel = channel;
    slot->termEncrypt = (encrypt != 0);

    wgtAnsiTermSetComm(termWidget, slot, commTermRead, commTermWrite);
    slot->attachedTerm = termWidget;

    // Register the secLink poller with the shell's idle registry so this
    // connection is pumped every idle frame regardless of foreground app.
    formRtCommIdleSync();
}


void CommClose(int32_t handle) {
    CommSlotT *slot = commGetSlot(handle);

    if (!slot) {
        return;
    }

    if (slot->attachedTerm) {
        wgtAnsiTermSetComm(slot->attachedTerm, NULL, NULL, NULL);
    }

    if (sCommApi.close && slot->link) {
        sCommApi.close(slot->link);
    }

    // Free the slot's ~512KB of channel buffers; leave a NULL hole at
    // this index so the handle number doesn't get reused for a different
    // connection until a fresh CommOpen fills the hole.
    free(slot);
    sCommSlots[handle - 1] = NULL;

    // Drop the secLink poller if that was the last open connection.
    formRtCommIdleSync();
}


void CommDetach(int32_t handle) {
    CommSlotT *slot = commGetSlot(handle);

    if (!slot || !slot->attachedTerm) {
        return;
    }

    wgtAnsiTermSetComm(slot->attachedTerm, NULL, NULL, NULL);
    slot->attachedTerm = NULL;
}


static CommSlotT *commGetSlot(int32_t handle) {
    if (handle < 1 || handle > (int32_t)arrlen(sCommSlots)) {
        return NULL;
    }

    CommSlotT *slot = sCommSlots[handle - 1];
    return (slot && slot->active) ? slot : NULL;
}


int32_t CommHandshake(int32_t handle) {
    CommSlotT *slot = commGetSlot(handle);

    if (!slot || !sCommApi.handshake) {
        return 0;
    }

    return (sCommApi.handshake(slot->link) == 0) ? -1 : 0;
}


// Idle callback: polls all active secLink connections
static void commIdlePoll(void *ctx) {
    (void)ctx;

    int32_t n = (int32_t)arrlen(sCommSlots);

    for (int32_t i = 0; i < n; i++) {
        CommSlotT *slot = sCommSlots[i];

        if (slot && slot->active && slot->link && sCommApi.poll) {
            sCommApi.poll(slot->link);
        }
    }
}


int32_t CommIsReady(int32_t handle) {
    CommSlotT *slot = commGetSlot(handle);

    if (!slot || !sCommApi.isReady) {
        return 0;
    }

    return sCommApi.isReady(slot->link) ? -1 : 0;
}


// SecLink receive callback: routes data into per-channel ring buffers
static void commOnRecv(void *ctx, const uint8_t *data, int len, uint8_t channel) {
    CommSlotT    *slot = (CommSlotT *)ctx;
    CommChanBufT *cb   = &slot->channels[channel & SECLINK_MAX_CHANNEL];

    for (int i = 0; i < len; i++) {
        int32_t next = (cb->head + 1) % SECLINK_CHAN_BUF_SIZE;

        if (next == cb->tail) {
            break;
        }

        cb->data[cb->head] = data[i];
        cb->head = next;
    }
}


int32_t CommOpen(int32_t com, int32_t baud, int32_t dataBits, const char *parity, int32_t stopBits, int32_t handshake) {
    if (!commResolveApi()) {
        return 0;
    }

    // Reuse an empty slot (hole from a previous close) if possible.
    // Otherwise grow the array by one pointer slot.
    int32_t handle = 0;
    int32_t n      = (int32_t)arrlen(sCommSlots);

    for (int32_t i = 0; i < n; i++) {
        if (!sCommSlots[i]) {
            handle = i + 1;
            break;
        }
    }

    if (handle == 0) {
        arrput(sCommSlots, NULL);
        handle = (int32_t)arrlen(sCommSlots);
    }

    CommSlotT *slot = (CommSlotT *)calloc(1, sizeof(CommSlotT));

    if (!slot) {
        return 0;
    }

    char parityChar = 'N';

    if (parity && parity[0]) {
        parityChar = parity[0];
    }

    slot->link = sCommApi.open(com, baud, dataBits, parityChar, stopBits, handshake, commOnRecv, slot);

    if (!slot->link) {
        free(slot);
        return 0;
    }

    slot->active = true;
    sCommSlots[handle - 1] = slot;
    return handle;
}


int32_t CommPending(int32_t handle) {
    CommSlotT *slot = commGetSlot(handle);

    if (!slot || !sCommApi.getPending) {
        return 0;
    }

    return sCommApi.getPending(slot->link);
}


int32_t CommPoll(int32_t handle) {
    CommSlotT *slot = commGetSlot(handle);

    if (!slot || !sCommApi.poll) {
        return 0;
    }

    return sCommApi.poll(slot->link);
}


const char *CommRecv(int32_t handle, int32_t channel) {
    CommSlotT *slot = commGetSlot(handle);

    if (!slot || channel < 0 || channel >= SECLINK_NUM_CHANNELS) {
        return "";
    }

    CommChanBufT *cb = &slot->channels[channel];
    static char   buf[SECLINK_CHAN_BUF_SIZE];
    int32_t       count = 0;

    while (count < (int32_t)sizeof(buf) - 1 && cb->tail != cb->head) {
        buf[count++] = cb->data[cb->tail];
        cb->tail     = (cb->tail + 1) % SECLINK_CHAN_BUF_SIZE;
    }

    buf[count] = '\0';
    return buf;
}


static bool commResolveApi(void) {
    if (sCommApiResolved) {
        return sCommApi.open != NULL;
    }

    sCommApiResolved = true;
    sCommApi.open       = dlsym(NULL, "_secLinkOpen");
    sCommApi.close      = dlsym(NULL, "_secLinkClose");
    sCommApi.handshake  = dlsym(NULL, "_secLinkHandshake");
    sCommApi.isReady    = dlsym(NULL, "_secLinkIsReady");
    sCommApi.poll       = dlsym(NULL, "_secLinkPoll");
    sCommApi.send       = dlsym(NULL, "_secLinkSend");
    sCommApi.sendBuf    = dlsym(NULL, "_secLinkSendBuf");
    sCommApi.getPending = dlsym(NULL, "_secLinkGetPending");

    return sCommApi.open != NULL;
}


int32_t CommSend(int32_t handle, const char *data, int32_t channel, int32_t encrypt) {
    CommSlotT *slot = commGetSlot(handle);

    if (!slot || !data || !sCommApi.sendBuf || channel < 0 || channel >= SECLINK_NUM_CHANNELS) {
        return 0;
    }

    int32_t len = (int32_t)strlen(data);

    if (len == 0) {
        return -1;
    }

    int rc = sCommApi.sendBuf(slot->link, (const uint8_t *)data, len, (uint8_t)channel, encrypt != 0);
    return (rc == 0) ? -1 : 0;
}


// AnsiTerm read callback: drains the terminal channel's ring buffer
static int32_t commTermRead(void *ctx, uint8_t *buf, int32_t maxLen) {
    CommSlotT    *slot = (CommSlotT *)ctx;
    CommChanBufT *cb   = &slot->channels[slot->termChannel];
    int32_t       count = 0;

    while (count < maxLen && cb->tail != cb->head) {
        buf[count++] = cb->data[cb->tail];
        cb->tail     = (cb->tail + 1) % SECLINK_CHAN_BUF_SIZE;
    }

    return count;
}


// AnsiTerm write callback: sends keystrokes on the terminal channel
static int32_t commTermWrite(void *ctx, const uint8_t *data, int32_t len) {
    CommSlotT *slot = (CommSlotT *)ctx;

    if (!slot->link || !sCommApi.send) {
        return 0;
    }

    int rc = sCommApi.send(slot->link, data, len, (uint8_t)slot->termChannel, slot->termEncrypt, false);
    return (rc == 0) ? len : 0;
}


// Create a DVX widget by type name using its registered API.
// The API's create function signature varies by widget type, so
// we call with sensible defaults for each creation pattern.
// For INT_INT types (e.g. PictureBox), hintW/hintH override the
// default createArgs if non-zero (so the bitmap matches the .frm size).

WidgetT *createWidget(const char *wgtTypeName, WidgetT *parent) {
    const void *api = wgtGetApi(wgtTypeName);

    if (!api) {
        return NULL;
    }

    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 *);
    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: {
            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;
            return fn(parent, NULL, 0, 0, 0);
        }
        default: {
            CreateParentFnT fn = *(CreateParentFnT *)api;
            return fn(parent);
        }
    }
}


// Authoritative name of a control for diagnostics and lookups.  The
// synthetic formCtrl carries no name copy of its own: form names hold
// up to BAS_MAX_IDENT - 1 chars, more than a BAS_MAX_IDENT
// buffer, so form->name is the single source of truth.
static const char *ctrlDisplayName(const BasControlT *ctrl) {
    if (ctrl->form && ctrl == &ctrl->form->formCtrl) {
        return ctrl->form->name;
    }

    return ctrl->name;
}


// Resolve a widget callback back to its BasControlT and runtime.  NULL
// (and *outRt unset) when the widget is not a BASIC control or its form
// has no VM to dispatch into.
static BasControlT *ctrlFromWidget(WidgetT *w, BasFormRtT **outRt) {
    BasControlT *ctrl = (BasControlT *)w->userData;

    if (!ctrl || !ctrl->form || !ctrl->form->vm) {
        return NULL;
    }

    *outRt = (BasFormRtT *)ctrl->form->vm->ui.ctx;
    return *outRt ? ctrl : NULL;
}


// Free a form's resources, window, and the BasFormT itself.  The form
// must already be out of rt->forms (or the caller is about to arrfree
// that array).  Clears the modal gate if this window held it.
static void destroyFormNow(BasFormRtT *rt, BasFormT *form) {
    basFormRtTeardownForm(rt, form);

    if (form->window) {
        if (rt->ctx->modalWindow == form->window) {
            rt->ctx->modalWindow = NULL;
        }

        dvxDestroyWindow(rt->ctx, form->window);
    }

    free(form);
}


// The unload commit point, shared by the deferred path, the immediate
// path, and the load-failure discard.  Makes the form unfindable
// (removed from rt->forms, so name lookups, Load/Show, and the
// onForm* window loops all miss it), invisible (window hidden, which
// also clears the modal gate -- dvxHideWindow owns that invariant),
// and event-inert (form->detached gates fireCtrlEvent).  Does NOT
// touch vm->currentForm/currentFormVars: while a handler is still
// running those must stay valid -- that is the point of deferral.  The
// fire wrappers restore them on unwind and unloadFormNow clears any
// survivors at free time.
static void detachFormForUnload(BasFormRtT *rt, BasFormT *form) {
    for (int32_t i = 0; i < (int32_t)arrlen(rt->forms); i++) {
        if (rt->forms[i] == form) {
            arrdel(rt->forms, i);
            break;
        }
    }

    form->detached = true;

    if (form->window) {
        dvxHideWindow(rt->ctx, form->window);
    }
}


// Control whose live widget is exactly w, or NULL.
static BasControlT *findCtrlByWidget(BasFormT *form, const WidgetT *w) {
    for (int32_t i = 0; i < (int32_t)arrlen(form->controls); i++) {
        if (form->controls[i]->widget == w) {
            return form->controls[i];
        }
    }

    return NULL;
}


// Case-insensitive scan of a single form's controls by bare name.  The
// shared core of every unindexed control lookup (basFormRtFindCtrl,
// basFormRtRemoveCtrl, findCtrlWidgetByName).  Control-array elements
// are never matched here: an unindexed reference to an array is an
// error in VB, and matching one would hit an arbitrary element.
static BasControlT *findCtrlInForm(BasFormT *form, const char *name) {
    for (int32_t i = 0; i < (int32_t)arrlen(form->controls); i++) {
        if (form->controls[i]->index < 0 && strcasecmp(form->controls[i]->name, name) == 0) {
            return form->controls[i];
        }
    }

    return NULL;
}


// Finds a control's live widget by name across every loaded form.  Shared by
// CommAttach/SerAttach to locate the terminal widget to bind a serial channel.
static WidgetT *findCtrlWidgetByName(const char *name) {
    if (!sFormRt || !name) {
        return NULL;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(sFormRt->forms); i++) {
        BasControlT *hit = findCtrlInForm(sFormRt->forms[i], name);

        if (hit) {
            return hit->widget;
        }
    }

    return NULL;
}


// Loaded form owning this window, or NULL.  Shared by every window
// callback (onForm*).
static BasFormT *findFormByWindow(const WindowT *win) {
    if (!sFormRt) {
        return NULL;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(sFormRt->forms); i++) {
        if (sFormRt->forms[i]->window == win) {
            return sFormRt->forms[i];
        }
    }

    return NULL;
}


// Menu item on a form by name, lazily creating its property proxy (a
// widget-less BasControlT carrying the menu id) on first access.
static BasControlT *findMenuProxyInForm(BasFormT *form, const char *name) {
    for (int32_t i = 0; i < form->menuIdMapCount; i++) {
        if (strcasecmp(form->menuIdMap[i].name, name) != 0) {
            continue;
        }

        if (!form->menuIdMap[i].proxy) {
            BasControlT *proxy = (BasControlT *)calloc(1, sizeof(BasControlT));

            if (proxy) {
                snprintf(proxy->name, BAS_MAX_IDENT, "%s", name);
                proxy->form   = form;
                proxy->menuId = form->menuIdMap[i].id;
                form->menuIdMap[i].proxy = proxy;
            }
        }

        return form->menuIdMap[i].proxy;
    }

    return NULL;
}


// Everything BASIC can name on one form: the form itself, its controls,
// then its menu items.
static BasControlT *findNamedObjectInForm(BasFormT *form, const char *name) {
    if (strcasecmp(form->name, name) == 0) {
        return &form->formCtrl;
    }

    BasControlT *hit = findCtrlInForm(form, name);

    if (hit) {
        return hit;
    }

    return findMenuProxyInForm(form, name);
}


// findPopupMenu -- look up a named popup menu on a form.  Returns
// NULL if no such menu exists.
static BasFrmPopupMenuT *findPopupMenu(BasFormT *form, const char *name) {
    if (!form || !name) {
        return NULL;
    }

    for (int32_t i = 0; i < form->popupMenuCount; i++) {
        if (strcasecmp(form->popupMenus[i].name, name) == 0) {
            return &form->popupMenus[i];
        }
    }

    return NULL;
}


// Case-insensitive descriptor lookup in a property table.
static const BasPropDescT *findPropDesc(const BasPropDescT *table, int32_t count, const char *name) {
    for (int32_t i = 0; i < count; i++) {
        if (strcasecmp(table[i].name, name) == 0) {
            return &table[i];
        }
    }

    return NULL;
}


static void fireCtrlEvent(BasFormRtT *rt, BasControlT *ctrl, const char *eventName, const BasValueT *args, int32_t argCount) {
    fireCtrlEventOut(rt, ctrl, eventName, args, argCount, NULL, 0);
}


// Full control-event dispatch: detached-form guard, same-event re-entry
// guard, control-array Index prefix, SetEvent overrides, and optional
// out-args (ByRef parameters such as Validate's Cancel) copied back from
// the handler's leading locals.  outArgs, when given, maps 1:1 onto args.
static void fireCtrlEventOut(BasFormRtT *rt, BasControlT *ctrl, const char *eventName, const BasValueT *args, int32_t argCount, BasValueT *outArgs, int32_t outArgCount) {
    // A detached form is committed to unload: its Unload event has
    // already fired, so no further events (Timer, key, mouse, blur
    // bridges) may be delivered to its controls.
    if (ctrl->form && ctrl->form->detached) {
        return;
    }

    // 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) {
            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.  The out
    // array gets the same prefix slot because the VM copies back the
    // handler's leading locals positionally.
    BasValueT       *allArgs       = NULL;
    BasValueT       *allOut        = NULL;
    const BasValueT *finalArgs     = args;
    BasValueT       *finalOut      = outArgs;
    int32_t          finalArgCount = argCount;
    int32_t          finalOutCount = outArgCount;

    if (ctrl->index >= 0) {
        allArgs = (BasValueT *)malloc(sizeof(BasValueT) * (argCount + 1));

        if (!allArgs) {
            return;
        }

        allArgs[0] = basValLong(ctrl->index);

        for (int32_t i = 0; i < argCount; i++) {
            allArgs[i + 1] = args[i];
        }

        finalArgs     = allArgs;
        finalArgCount = argCount + 1;

        if (outArgs && outArgCount > 0) {
            allOut = (BasValueT *)calloc(outArgCount + 1, sizeof(BasValueT));

            if (!allOut) {
                free(allArgs);
                return;
            }

            finalOut      = allOut;
            finalOutCount = outArgCount + 1;
        }
    }

    // Resolve any SetEvent override FIRST.  An override entry consumes
    // the event even when its named SUB is missing or is a FUNCTION --
    // it must never fall back to the CtrlName_EventName convention.
    const BasProcEntryT *overrideProc = NULL;
    bool                 overridden   = false;

    for (int32_t i = 0; i < ctrl->eventOverrideCount; i++) {
        if (strcasecmp(ctrl->eventOverrides[i].eventName, eventName) == 0) {
            overridden = true;

            if (rt->vm && rt->module) {
                overrideProc = basModuleFindProc(rt->module, ctrl->eventOverrides[i].handlerName);
            }

            break;
        }
    }

    if (overridden && (!overrideProc || overrideProc->isFunction)) {
        free(allArgs);
        free(allOut);
        return;
    }

    // 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);
    }

    // Rule-N native-bridge bracket: the eventFiring writes after the
    // dispatch below must land on live memory, so a deferred unload (or
    // RemoveControl) of this control cannot flush before they run.
    rtEventEnter(rt);

    if (overridden) {
        rt->vm->errorMsg[0] = '\0';
        rt->vm->errorNumber = 0;
        rtCallHandler(rt, ctrl->form, overrideProc, overrideProc->codeAddr, finalArgs, finalArgCount, finalOut, finalOutCount);
    } else {
        fireEventArgsOut(rt, ctrl->form, ctrl->name, eventName, finalArgs, finalArgCount, finalOut, finalOutCount);
    }

    if (claimedGuard) {
        ctrl->eventFiring        = false;
        ctrl->firingEventName[0] = '\0';
    }

    // Strip the Index prefix back out of the returned out-args.
    if (allOut) {
        basValRelease(&allOut[0]);

        for (int32_t i = 0; i < outArgCount; i++) {
            basValRelease(&outArgs[i]);
            outArgs[i] = allOut[i + 1];
        }
    }

    free(allArgs);
    free(allOut);
    rtEventLeave(rt);
}


// Locate CtrlName_EventName and call it.  The SUB must declare exactly
// the number of parameters the event provides, or none (then it is
// called with no args and nothing is copied back).  outArgs, when
// given, receives the handler's leading locals positionally (ByRef
// event parameters such as Cancel).  Single source of truth for the
// Args, WithCancel, and control-event fire paths.
static bool fireEventArgsOut(BasFormRtT *rt, BasFormT *form, const char *ctrlName, const char *eventName, const BasValueT *args, int32_t argCount, BasValueT *outArgs, int32_t outArgCount) {
    if (!rt || !form || !rt->vm || !rt->module) {
        return false;
    }

    char handlerName[MAX_EVENT_NAME_LEN];
    snprintf(handlerName, sizeof(handlerName), "%s_%s", ctrlName, eventName);

    const BasProcEntryT *proc = basModuleFindProc(rt->module, handlerName);

    if (!proc || proc->isFunction) {
        return false;
    }

    if (proc->paramCount == 0) {
        return rtCallHandler(rt, form, proc, proc->codeAddr, NULL, 0, NULL, 0);
    }

    if (proc->paramCount != argCount) {
        return false;
    }

    return rtCallHandler(rt, form, proc, proc->codeAddr, args, argCount, outArgs, outArgCount);
}


// Comma-separated list of the table's readable (or writable) property
// names for "Valid: ..." diagnostics.
static void formatPropNames(const BasPropDescT *table, int32_t count, bool forWrite, char *buf, int32_t bufSize) {
    int32_t pos = 0;

    buf[0] = '\0';

    for (int32_t i = 0; i < count; i++) {
        bool ok = forWrite ? table[i].writable : table[i].readable;

        if (!ok || pos >= bufSize) {
            continue;
        }

        pos += snprintf(buf + pos, bufSize - pos, "%s%s", pos ? ", " : "", table[i].name);
    }
}


// Register or unregister the secLink idle poller with the shell based on
// whether any connection is still open.  Idempotent on both sides, so this
// is safe to call from every CommAttach and CommClose.  commIdlePoll keeps
// every open link pumped (handshake/retransmit) regardless of which app is
// foreground, so it stays registered until the last connection closes.
static void formRtCommIdleSync(void) {
    bool needed = false;

    for (int32_t i = 0; i < (int32_t)arrlen(sCommSlots); i++) {
        if (sCommSlots[i] && sCommSlots[i]->active) {
            needed = true;
            break;
        }
    }

    if (needed) {
        if (resolveShellIdle()) {
            sShellRegisterIdle(commIdlePoll, NULL);
        } else {
            dvxLog("BASIC: shell idle registry unavailable; secLink polling disabled");
        }
    } else if (sShellUnregisterIdle) {
        sShellUnregisterIdle(commIdlePoll, NULL);
    }
}


// Detach any raw-serial or secLink terminal binding whose AnsiTerm widget
// lives on this form.  Called from basFormRtTeardownForm before the form's
// controls array is freed and its widgets are destroyed; without this,
// serIdlePoll and the later Comm/Ser close paths would dereference the
// freed widget.  The port/connection itself stays open -- only the
// terminal binding is dropped, mirroring SerDetach/CommDetach.
static void formRtDetachTermsForForm(BasFormT *form) {
    bool serDetached = false;

    for (int32_t i = 0; i < (int32_t)arrlen(form->controls); i++) {
        WidgetT *widget = form->controls[i]->widget;

        if (!widget) {
            continue;
        }

        if (formRtDetachTermsForWidget(widget)) {
            serDetached = true;
        }
    }

    // Drop the raw-serial idle poller if no attached terminal remains.
    // The secLink poller stays registered while any connection is open
    // (it pumps handshake/retransmit even without a terminal).
    if (serDetached) {
        formRtSerIdleSync();
    }
}


// Detach any raw-serial or secLink terminal binding on a single widget.
// Returns true if a raw-serial binding was dropped -- the caller then
// syncs the idle poller via formRtSerIdleSync.  Shared by the per-form
// teardown loop above and basFormRtRemoveCtrl's immediate widget destroy.
static bool formRtDetachTermsForWidget(WidgetT *widget) {
    bool serDetached = false;

    for (int32_t j = 0; j < RS232_NUM_PORTS; j++) {
        if (sSerAttach[j].attached && sSerAttach[j].term == widget) {
            wgtAnsiTermSetComm(widget, NULL, NULL, NULL);
            sSerAttach[j].attached = false;
            sSerAttach[j].term     = NULL;
            serDetached            = true;
        }
    }

    for (int32_t j = 0; j < (int32_t)arrlen(sCommSlots); j++) {
        if (sCommSlots[j] && sCommSlots[j]->attachedTerm == widget) {
            wgtAnsiTermSetComm(widget, NULL, NULL, NULL);
            sCommSlots[j]->attachedTerm = NULL;
        }
    }

    return serDetached;
}


// Register or unregister the raw-serial idle poller with the shell based on
// whether any port is still attached to a terminal.  Idempotent, so it is
// safe to call from SerAttach, SerDetach, and SerClose.  serIdlePoll only
// services attached terminals, so it is dropped as soon as none remain.
static void formRtSerIdleSync(void) {
    bool needed = false;

    for (int32_t i = 0; i < RS232_NUM_PORTS; i++) {
        if (sSerAttach[i].attached) {
            needed = true;
            break;
        }
    }

    if (needed) {
        if (resolveShellIdle()) {
            sShellRegisterIdle(serIdlePoll, NULL);
        } else {
            dvxLog("BASIC: shell idle registry unavailable; serial polling disabled");
        }
    } else if (sShellUnregisterIdle) {
        sShellUnregisterIdle(serIdlePoll, NULL);
    }
}


// Free a control and its heap-owned tooltip.  NULL-safe: also used for
// menuIdMap proxies, which are calloc'd with no tooltip (free(NULL) is
// a no-op), so this is the single owner of control teardown.
static void freeControl(BasControlT *ctrl) {
    if (!ctrl) {
        return;
    }

    free(ctrl->tooltip);
    free(ctrl);
}


static void frmLoadOnCtrlBegin(void *userData, const char *typeName, const char *name) {
    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;

    if (!ctx->form || ctx->nestDepth <= 0 || ctx->rt->terminated) {
        return;
    }

    // Clear any stale pointer first: if this control fails to create (unknown
    // type or NULL widget) ctx->current stays NULL, so frmLoadOnCtrlProp's
    // guard drops its props instead of overwriting the previous control's.
    ctx->current = NULL;

    // Lazy-create the content box once we know the form layout.  contentBox
    // is initialized to root, so test for "still the default root box" rather
    // than NULL -- otherwise a form-level Layout (HBox/WrapBox) never fires.
    if (ctx->form->contentBox == ctx->form->root && 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) {
        // Unknown widget type in .frm -- catch typos ("Spinner" instead
        // of "SpinButton") that would otherwise silently drop the
        // control and surface later as a misleading "control not
        // found" runtime error.  The IDE validator blocks these at
        // compile time; this is the safety net for bascomp standalone.
        // basFormRtLoadFrm sees rt->terminated and discards the form.
        basFormRtRuntimeError(ctx->rt,
            "Unknown widget type in form",
            "Form: %s\nControl: %s\nType: %s\nNot a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner).",
            ctx->form->name,
            name ? name : "(null)",
            typeName ? typeName : "(null)");
        return;
    }

    WidgetT *parent = ctx->parentStack[ctx->nestDepth - 1];
    WidgetT *widget = createWidget(wgtTypeName, parent);

    if (widget) {
        const WgtIfaceT *ctrlIface = wgtGetIface(wgtTypeName);

        wgtSetName(widget, name);

        BasControlT *ctrlEntry = (BasControlT *)calloc(1, sizeof(BasControlT));

        if (ctrlEntry) {
            snprintf(ctrlEntry->name, BAS_MAX_IDENT, "%s", name);
            snprintf(ctrlEntry->typeName, BAS_MAX_IDENT, "%s", typeName);
            ctrlEntry->index  = -1;
            ctrlEntry->widget = widget;
            ctrlEntry->form   = ctx->form;
            ctrlEntry->iface  = ctrlIface;
            arrput(ctx->form->controls, ctrlEntry);

            ctx->current = ctrlEntry;

            wireWidgetEvents(widget, ctrlEntry);
        }

        isCtrlContainer = ctrlIface && ctrlIface->isContainer;

        if (isCtrlContainer && ctx->nestDepth < FRM_MAX_NESTING) {
            ctx->parentStack[ctx->nestDepth++] = widget;
        }
    }

    if (ctx->containerDepth < FRM_MAX_NESTING) {
        ctx->containerStack[ctx->containerDepth++] = isCtrlContainer;
    }
}


static void frmLoadOnCtrlEnd(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 frmLoadOnCtrlProp(void *userData, const char *key, const char *value) {
    BasFrmLoadCtxT *ctx  = (BasFrmLoadCtxT *)userData;
    BasControlT    *ctrl = ctx->current;

    if (!ctrl || ctx->rt->terminated) {
        return;
    }

    // Control array index is stored on the struct, not as a widget property
    if (strcasecmp(key, "Index") == 0) {
        ctrl->index = atoi(value);
        return;
    }

    char scratch[FRM_MAX_LINE_LEN];
    snprintf(scratch, sizeof(scratch), "%s", value);
    frmStripQuotes(scratch);

    // 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->nestDepth > 0) {
        if (strcasecmp(scratch, "VBox") != 0) {
            ctx->parentStack[ctx->nestDepth - 1] = basFormRtCreateContentBox(ctrl->widget, scratch);
        }

        return;
    }

    // Widget-declared properties are classified by the descriptor's own
    // type -- enum by name, True/False, decimal, or string -- never by
    // how the value happened to be quoted.  A read-only descriptor is
    // recognized and ignored, as at runtime.
    const WgtPropDescT *ip = ctrl->iface ? wgtIfaceFindProp(ctrl->iface, key) : NULL;

    if (ip) {
        if (ip->setFn && !wgtApplyPropFromString(ctrl->widget, ip, scratch)) {
            basFormRtRuntimeError(ctx->rt,
                "Bad property value in form",
                "Form: %s\nControl: %s\nProperty: %s\nValue: %s\nNot a valid value for this property.",
                ctx->form->name, ctrl->name, key, scratch);
        }

        return;
    }

    // Common runtime-owned properties are typed by the shared table.
    // Anything else goes through as a string so basFormRtSetProp reports
    // the unknown name.
    const BasPropDescT *cp = basFormRtFindCommonProp(key);
    BasValueT           val;

    if (cp && cp->type == WGT_IFACE_BOOL) {
        val = basValBool(frmParseBool(scratch));
    } else if (cp && cp->type == WGT_IFACE_INT) {
        val = basValLong(atoi(scratch));
    } else {
        val = basValStringFromC(scratch);
    }

    basFormRtSetProp(ctx->rt, ctrl, key, val);
    basValRelease(&val);
}


static bool frmLoadOnFormBegin(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;
}


// Form-level .frm properties.  Geometry and flags are accumulated on the
// BasFormT and applied by basFormRtLoadFrm after every control exists
// (window fitting needs the final control set); only Caption is
// immediate.  The key is validated and typed through sFormProps.
static void frmLoadOnFormProp(void *userData, const char *key, const char *value) {
    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;

    if (!ctx->form) {
        return;
    }

    const BasPropDescT *pd = basFormRtFindFormProp(key);

    if (!pd) {
        return;
    }

    char text[FRM_MAX_LINE_LEN];
    snprintf(text, sizeof(text), "%s", value);
    frmStripQuotes(text);

    int32_t  num  = (pd->type == WGT_IFACE_INT) ? atoi(text) : 0;
    bool     flag = (pd->type == WGT_IFACE_BOOL) && frmParseBool(text);
    BasFormT *frm = ctx->form;

    switch ((BasFormPropE)(pd - sFormProps)) {
        case FORM_PROP_CAPTION:
            dvxSetTitle(ctx->rt->ctx, frm->window, text);
            break;

        case FORM_PROP_WIDTH:
            frm->frmWidth = num;
            break;

        case FORM_PROP_HEIGHT:
            frm->frmHeight = num;
            break;

        case FORM_PROP_LEFT:
            frm->frmLeft = num;
            break;

        case FORM_PROP_TOP:
            frm->frmTop = num;
            break;

        case FORM_PROP_RESIZABLE:
            frm->frmResizable    = flag;
            frm->frmHasResizable = true;
            break;

        case FORM_PROP_CENTERED:
            frm->frmCentered = flag;
            break;

        case FORM_PROP_AUTOSIZE:
            frm->frmAutoSize = flag;
            break;

        case FORM_PROP_LAYOUT:
            snprintf(frm->frmLayout, sizeof(frm->frmLayout), "%s", text);
            break;

        case FORM_PROP_HELPTOPIC:
            snprintf(frm->helpTopic, sizeof(frm->helpTopic), "%s", text);
            break;

        case FORM_PROP_NAME:
        case FORM_PROP_VISIBLE:
        case FORM_PROP_CONTEXTMENU:
        case FORM_PROP_COUNT:
            // Name comes from the Begin line; Visible and ContextMenu are
            // runtime-only (the menu does not exist until the parse ends).
            break;
    }
}


static void frmLoadOnMenuBegin(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_IDENT, "%s", name);
    mi.level   = level;
    mi.enabled = true;
    mi.visible = true;
    arrput(ctx->menuItems, mi);
    ctx->curMenuItemIdx = (int32_t)arrlen(ctx->menuItems) - 1;
    ctx->current        = NULL;
}


static void frmLoadOnMenuEnd(void *userData) {
    BasFrmLoadCtxT *ctx = (BasFrmLoadCtxT *)userData;

    ctx->curMenuItemIdx = -1;
}


static void frmLoadOnMenuProp(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[FRM_MAX_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: an unrecognized value leaves the item enabled.
        mip->enabled = frmParseBoolDefault(text, true);
    } else if (strcasecmp(key, "Visible") == 0) {
        // Top-level only: Visible=False marks the menu as a popup
        // (not part of the menu bar).  Ignored on nested items.
        mip->visible = frmParseBoolDefault(text, true);
    }
}


static BasValueT getCommonProp(BasControlT *ctrl, const char *propName, bool *handled) {
    const BasPropDescT *pd = basFormRtFindCommonProp(propName);
    WidgetT            *w  = ctrl->widget;

    *handled = (pd != NULL);

    if (!pd) {
        return zeroValue();
    }

    switch ((BasCtrlPropE)(pd - sCtrlProps)) {
        case CTRL_PROP_NAME:
            return basValStringFromC(ctrl->name);

        case CTRL_PROP_LEFT:
            return basValLong(w->x);

        case CTRL_PROP_TOP:
            return basValLong(w->y);

        case CTRL_PROP_WIDTH:
        case CTRL_PROP_MINWIDTH:
            return basValLong(w->w);

        case CTRL_PROP_HEIGHT:
        case CTRL_PROP_MINHEIGHT:
            return basValLong(w->h);

        case CTRL_PROP_MAXWIDTH:
            return basValLong(w->maxW ? (w->maxW & WGT_SIZE_VAL_MASK) : 0);

        case CTRL_PROP_MAXHEIGHT:
            return basValLong(w->maxH ? (w->maxH & WGT_SIZE_VAL_MASK) : 0);

        case CTRL_PROP_WEIGHT:
            return basValLong(w->weight);

        case CTRL_PROP_VISIBLE:
            return basValBool(w->visible);

        case CTRL_PROP_ENABLED:
            return basValBool(w->enabled);

        case CTRL_PROP_READONLY:
            return basValBool(w->readOnly);

        case CTRL_PROP_BACKCOLOR:
            return basValLong((int32_t)w->bgColor);

        case CTRL_PROP_FORECOLOR:
            return basValLong((int32_t)w->fgColor);

        case CTRL_PROP_CAPTION:
        case CTRL_PROP_TEXT: {
            // Caption and Text both map to the widget's text.
            const char *text = wgtGetText(w);
            return basValStringFromC(text ? text : "");
        }

        case CTRL_PROP_TOOLTIPTEXT:
            return basValStringFromC(ctrl->tooltip ? ctrl->tooltip : "");

        case CTRL_PROP_CONTEXTMENU:
            return basValStringFromC(popupMenuNameFor(ctrl->form, w->contextMenu));

        case CTRL_PROP_HELPTOPIC:
            return basValStringFromC(ctrl->helpTopic);

        case CTRL_PROP_DATASOURCE:
            return basValStringFromC(ctrl->dataSource);

        case CTRL_PROP_DATAFIELD:
            return basValStringFromC(ctrl->dataField);

        case CTRL_PROP_LISTCOUNT: {
            // Any widget with item storage exposes a ListCount method.
            const WgtMethodDescT *m = ifaceFindMethod(ctrl->iface, "ListCount", WGT_SIG_RET_INT);
            return basValLong(m ? ((int32_t (*)(const WidgetT *))m->fn)(w) : 0);
        }

        case CTRL_PROP_COUNT:
            break;
    }

    return zeroValue();
}


// FormName.Prop reads: window geometry/state plus the BasFormT flags.
static BasValueT getFormProp(BasFormRtT *rt, BasFormT *frm, const char *propName) {
    WindowT            *win = frm->window;
    const BasPropDescT *pd  = basFormRtFindFormProp(propName);

    if (!pd || !pd->readable) {
        char valid[BAS_PROP_LIST_LEN];
        formatPropNames(sFormProps, FORM_PROP_COUNT, false, valid, sizeof(valid));
        basFormRtRuntimeError(rt,
            "Unknown form property",
            "Form: %s\nProperty: %s\nValid: %s.",
            frm->name, propName, valid);
        return zeroValue();
    }

    switch ((BasFormPropE)(pd - sFormProps)) {
        case FORM_PROP_NAME:
            return basValStringFromC(frm->name);

        case FORM_PROP_CAPTION:
            return basValStringFromC(win ? win->title : "");

        case FORM_PROP_WIDTH:
            return basValLong(win ? win->w : 0);

        case FORM_PROP_HEIGHT:
            return basValLong(win ? win->h : 0);

        case FORM_PROP_LEFT:
            return basValLong(win ? win->x : 0);

        case FORM_PROP_TOP:
            return basValLong(win ? win->y : 0);

        case FORM_PROP_VISIBLE:
            return basValBool(win && win->visible);

        case FORM_PROP_RESIZABLE:
            return basValBool(win && win->resizable);

        case FORM_PROP_AUTOSIZE:
            return basValBool(frm->frmAutoSize);

        case FORM_PROP_CENTERED:
            return basValBool(frm->frmCentered);

        case FORM_PROP_LAYOUT:
            return basValStringFromC(frm->frmLayout);

        case FORM_PROP_CONTEXTMENU:
            return basValStringFromC(popupMenuNameFor(frm, win ? win->contextMenu : NULL));

        case FORM_PROP_HELPTOPIC:
            return basValStringFromC(frm->helpTopic);

        case FORM_PROP_COUNT:
            break;
    }

    return zeroValue();
}


// Look up a property in the interface descriptor and call its
// getter, marshaling the return value to BasValueT.

static BasValueT getIfaceProp(const WgtIfaceT *iface, WidgetT *w, const char *propName, bool *handled) {
    *handled = false;

    const WgtPropDescT *p = wgtIfaceFindProp(iface, propName);

    if (!p) {
        return zeroValue();
    }

    *handled = true;

    if (!p->getFn) {
        return zeroValue();
    }

    switch (p->type) {
        case WGT_IFACE_STRING: {
            const char *s = ((const char *(*)(WidgetT *))p->getFn)(w);
            return basValStringFromC(s ? s : "");
        }

        case WGT_IFACE_ENUM: {
            int32_t v = ((int32_t (*)(const WidgetT *))p->getFn)(w);
            return basValLong(v);
        }

        case WGT_IFACE_INT: {
            int32_t v = ((int32_t (*)(const WidgetT *))p->getFn)(w);
            return basValLong(v);
        }

        case WGT_IFACE_BOOL: {
            bool v = ((bool (*)(const WidgetT *))p->getFn)(w);
            return basValBool(v);
        }

        case WGT_IFACE_FLOAT: {
            float v = ((float (*)(const WidgetT *))p->getFn)(w);
            return basValSingle(v);
        }
    }

    return zeroValue();
}


int32_t HelpCompile(const char *inputFile, const char *outputFile) {
    if (!sHlpcResolved) {
        sHlpcResolved = true;
        sHlpcCompile  = dlsym(NULL, "_hlpcCompile");
    }

    if (!sHlpcCompile || !inputFile || !outputFile) {
        return 0;
    }

    const char *inputs[1];
    inputs[0] = inputFile;

    int32_t rc = sHlpcCompile(inputs, 1, outputFile, NULL, NULL, HLPC_QUIET, NULL, NULL);
    return (rc == 0) ? -1 : 0;
}


void HelpView(const char *hlpFile) {
    if (!sShellLoadResolved) {
        sShellLoadResolved = true;
        sShellLoadApp      = dlsym(NULL, "_shellLoadAppWithArgs");
    }

    if (!sShellLoadApp || !sFormRt || !sFormRt->ctx || !hlpFile) {
        return;
    }

    // If hlpFile has no directory component, resolve it against the calling
    // app's directory. This matches the convention where apps bundle their
    // help file alongside the .app.
    char resolved[DVX_MAX_PATH];
    bool hasDir = strchr(hlpFile, '/') != NULL || strchr(hlpFile, '\\') != NULL || (hlpFile[0] && hlpFile[1] == ':');

    if (!hasDir && sFormRt->vm && sFormRt->vm->appPath[0]) {
        snprintf(resolved, sizeof(resolved), "%s" DVX_PATH_SEP "%s", sFormRt->vm->appPath, hlpFile);
    } else {
        snprintf(resolved, sizeof(resolved), "%s", hlpFile);
    }

    sShellLoadApp(sFormRt->ctx, DVX_HELP_VIEWER_APP_LITERAL, resolved);
}


// Enumerator index for a named value of an ENUM property, or -1 when the
// name (or the property's name table) is absent.  Shared by the runtime
// setter and the .frm/designer string path so both agree on unknowns.
static int32_t ifaceEnumIndex(const WgtPropDescT *p, const char *name) {
    if (!p->enumNames || !name) {
        return -1;
    }

    for (int32_t en = 0; p->enumNames[en]; en++) {
        if (strcasecmp(p->enumNames[en], name) == 0) {
            return en;
        }
    }

    return -1;
}


// Interface method by name and signature, or NULL.  A NULL iface is a
// miss, so callers need no separate guard.
static const WgtMethodDescT *ifaceFindMethod(const WgtIfaceT *iface, const char *name, uint8_t sig) {
    if (!iface) {
        return NULL;
    }

    for (int32_t m = 0; m < iface->methodCount; m++) {
        if (iface->methods[m].sig == sig && strcasecmp(iface->methods[m].name, name) == 0) {
            return &iface->methods[m];
        }
    }

    return NULL;
}


// Read a STRING interface property through its getter; NULL when the
// control has no such readable property.
static const char *ifaceGetStringProp(const BasControlT *ctrl, const char *propName) {
    if (!ctrl->iface || !ctrl->widget) {
        return NULL;
    }

    const WgtPropDescT *pd = wgtIfaceFindProp(ctrl->iface, propName);

    if (!pd || pd->type != WGT_IFACE_STRING || !pd->getFn) {
        return NULL;
    }

    return ((const char *(*)(const WidgetT *))pd->getFn)(ctrl->widget);
}


// True if target is tree itself or any submenu nested within it (any depth).
// Used by DestroyMenu to purge every popupMenus alias and contextMenu pointer
// that references a tree about to be freed.
static bool menuContainsMenu(const MenuT *tree, const MenuT *target) {
    if (!tree || !target) {
        return false;
    }

    if (tree == target) {
        return true;
    }

    for (int32_t i = 0; i < tree->itemCount; i++) {
        if (menuContainsMenu(tree->items[i].subMenu, target)) {
            return true;
        }
    }

    return false;
}


// Allocate the next menu-item ID for a form.  IDs are unique across
// menu bar + popups on the same form and reused by menuIdMap for
// event dispatch.  Starts from MENU_ID_BASE + the highest in-use ID
// so runtime-added items don't collide with .frm-loaded items.
static int32_t nextMenuItemId(BasFormT *form) {
    int32_t maxId = MENU_ID_BASE - 1;

    for (int32_t i = 0; i < form->menuIdMapCount; i++) {
        if (form->menuIdMap[i].id > maxId) {
            maxId = form->menuIdMap[i].id;
        }
    }

    return maxId + 1;
}


static void onFormActivate(WindowT *win) {
    // Chain the default widget focus handler: this overrides
    // wgtInitWindow's win->onFocus, so without this the widget focus
    // bridge (icon refresh, focus bookkeeping) never runs for forms.
    widgetOnFocus(win);

    BasFormT *form = findFormByWindow(win);

    if (form) {
        basFormRtFireEvent(sFormRt, form, form->name, BAS_EVT_ACTIVATE);
    }
}


// Called when the user closes a BASIC form's window.  Thin wrapper over
// basFormRtUnloadForm so the close box takes the exact same
// QueryUnload/Unload/teardown path (including the `unloading`
// re-entrancy guard and deferred frees) as the UNLOAD statement.

static void onFormClose(WindowT *win) {
    BasFormT *form = findFormByWindow(win);

    if (!form) {
        return;
    }

    basFormRtUnloadForm(sFormRt, form);

    // If no forms are left, stop the VM.  A QueryUnload cancel leaves the
    // form in sFormRt->forms, so this stays false then; a committed
    // unload is detached (removed) even when its frees are deferred.
    if (arrlen(sFormRt->forms) == 0 && sFormRt->vm) {
        sFormRt->vm->running = false;
    }
}


static void onFormDeactivate(WindowT *win) {
    // Chain the default widget focus handler: it clears sFocusedWidget
    // and fires the focused widget's onBlur (BASIC LostFocus event +
    // data-binding write-back), which this override otherwise suppresses.
    widgetOnBlur(win);

    BasFormT *form = findFormByWindow(win);

    if (form) {
        basFormRtFireEvent(sFormRt, form, form->name, BAS_EVT_DEACTIVATE);
    }
}


// Dispatch menu clicks as ControlName_Click events.
static void onFormMenu(WindowT *win, int32_t menuId) {
    BasFormT *form = findFormByWindow(win);

    if (!form) {
        return;
    }

    for (int32_t j = 0; j < form->menuIdMapCount; j++) {
        if (form->menuIdMap[j].id == menuId) {
            basFormRtFireEvent(sFormRt, form, form->menuIdMap[j].name, BAS_EVT_CLICK);
            return;
        }
    }
}


static void onFormResize(WindowT *win, int32_t newW, int32_t newH) {
    // Let the widget system re-evaluate scrollbars for the new size
    widgetOnResize(win, newW, newH);

    BasFormT *form = findFormByWindow(win);

    if (form) {
        basFormRtFireEvent(sFormRt, form, form->name, BAS_EVT_RESIZE);
    }
}


static void onWidgetBlur(WidgetT *w) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (!ctrl) {
        return;
    }

    // Rule-N native-bridge bracket: the data-binding write-back below
    // reads ctrl and ctrl->form AFTER the LostFocus fire, so a deferred
    // unload from the handler must not flush (and free them) until the
    // bracket closes.
    rtEventEnter(rt);
    fireCtrlEvent(rt, ctrl, BAS_EVT_LOSTFOCUS, NULL, 0);

    // Write-back: if this control is data-bound, update the Data control's
    // cache and persist to the database
    if (ctrl->dataSource[0] && ctrl->dataField[0] && ctrl->widget && ctrl->form) {
        for (int32_t i = 0; i < (int32_t)arrlen(ctrl->form->controls); i++) {
            BasControlT *dc = ctrl->form->controls[i];

            if (strcasecmp(dc->typeName, "Data") == 0 &&
                strcasecmp(dc->name, ctrl->dataSource) == 0 &&
                dc->widget) {
                const char *text = wgtGetText(ctrl->widget);

                if (text) {
                    wgtDataCtrlSetField(dc->widget, ctrl->dataField, text);
                    wgtDataCtrlUpdate(dc->widget);
                }

                break;
            }
        }
    }

    rtEventLeave(rt);
}


static void onWidgetChange(WidgetT *w) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (!ctrl) {
        return;
    }

    // Data controls fire "Reposition", update bound controls, and cascade to details.
    // Rule-N native-bridge bracket: refreshDetailControls reads
    // ctrl->form AFTER the Reposition fire, so a deferred unload from
    // the handler must not flush until the bracket closes.
    if (strcasecmp(ctrl->typeName, "Data") == 0) {
        rtEventEnter(rt);
        updateBoundControls(ctrl->form, ctrl);
        fireCtrlEvent(rt, ctrl, BAS_EVT_REPOSITION, NULL, 0);
        refreshDetailControls(ctrl->form, ctrl);
        rtEventLeave(rt);
        return;
    }

    // Timer widgets fire "Timer" event, everything else fires "Change"
    const char *evtName = (strcasecmp(ctrl->typeName, "Timer") == 0) ? BAS_EVT_TIMER : BAS_EVT_CHANGE;
    fireCtrlEvent(rt, ctrl, evtName, NULL, 0);
}


static void onWidgetClick(WidgetT *w) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (ctrl) {
        fireCtrlEvent(rt, ctrl, BAS_EVT_CLICK, NULL, 0);
    }
}


static void onWidgetDblClick(WidgetT *w) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (ctrl) {
        fireCtrlEvent(rt, ctrl, BAS_EVT_DBLCLICK, NULL, 0);
    }
}


static void onWidgetFocus(WidgetT *w) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (ctrl) {
        fireCtrlEvent(rt, ctrl, BAS_EVT_GOTFOCUS, NULL, 0);
    }
}


static void onWidgetKeyDown(WidgetT *w, int32_t keyCode, int32_t shift) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (ctrl) {
        BasValueT args[2];
        args[0] = basValLong(keyCode);
        args[1] = basValLong(shift);
        fireCtrlEvent(rt, ctrl, BAS_EVT_KEYDOWN, args, 2);
    }
}


static void onWidgetKeyPress(WidgetT *w, int32_t keyAscii) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (ctrl) {
        BasValueT args[1];
        args[0] = basValLong(keyAscii);
        fireCtrlEvent(rt, ctrl, BAS_EVT_KEYPRESS, args, 1);
    }
}


static void onWidgetKeyUp(WidgetT *w, int32_t keyCode, int32_t shift) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (ctrl) {
        BasValueT args[2];
        args[0] = basValLong(keyCode);
        args[1] = basValLong(shift);
        fireCtrlEvent(rt, ctrl, BAS_EVT_KEYUP, args, 2);
    }
}


static void onWidgetMouseDown(WidgetT *w, int32_t button, int32_t x, int32_t y) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (ctrl) {
        BasValueT args[3];
        args[0] = basValLong(button);
        args[1] = basValLong(x);
        args[2] = basValLong(y);
        fireCtrlEvent(rt, ctrl, BAS_EVT_MOUSEDOWN, args, 3);
    }
}


static void onWidgetMouseMove(WidgetT *w, int32_t button, int32_t x, int32_t y) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (ctrl) {
        BasValueT args[3];
        args[0] = basValLong(button);
        args[1] = basValLong(x);
        args[2] = basValLong(y);
        fireCtrlEvent(rt, ctrl, BAS_EVT_MOUSEMOVE, args, 3);
    }
}


static void onWidgetMouseUp(WidgetT *w, int32_t button, int32_t x, int32_t y) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (ctrl) {
        BasValueT args[3];
        args[0] = basValLong(button);
        args[1] = basValLong(x);
        args[2] = basValLong(y);
        fireCtrlEvent(rt, ctrl, BAS_EVT_MOUSEUP, args, 3);
    }
}


static void onWidgetScroll(WidgetT *w, int32_t delta) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (ctrl) {
        BasValueT args[1];
        args[0] = basValLong(delta);
        fireCtrlEvent(rt, ctrl, BAS_EVT_SCROLL, args, 1);
    }
}


// Validate(Cancel As Integer): goes through the full control-event path
// (detached-form guard, SetEvent override, control-array Index prefix)
// with Cancel as an out-arg.  Returns false when the handler cancelled.
static bool onWidgetValidate(WidgetT *w) {
    BasFormRtT  *rt;
    BasControlT *ctrl = ctrlFromWidget(w, &rt);

    if (!ctrl) {
        return true;
    }

    BasValueT cancelArg = basValLong(0);
    BasValueT outCancel = basValLong(0);

    fireCtrlEventOut(rt, ctrl, BAS_EVT_VALIDATE, &cancelArg, 1, &outCancel, 1);

    bool cancelled = basValIsTruthy(outCancel);
    basValRelease(&outCancel);
    return !cancelled;
}


static int32_t parseFileFilters(const char *filter, FileFilterT **outFilters, char *buf, int32_t bufSize) {
    *outFilters = NULL;

    if (!filter || !filter[0]) {
        FileFilterT *out = (FileFilterT *)calloc(1, sizeof(FileFilterT));

        if (!out) {
            return 0;
        }

        out[0].label = "All Files (*.*)";
        *outFilters  = out;
        return 1;
    }

    // No pipe and no parentheses = old-style single pattern, wrap it
    if (!strchr(filter, '|') && !strchr(filter, '(')) {
        FileFilterT *out = (FileFilterT *)calloc(2, sizeof(FileFilterT));

        if (!out) {
            return 0;
        }

        snprintf(buf, bufSize, "%s (%s)", filter, filter);
        out[0].label = buf;
        out[1].label = "All Files (*.*)";
        *outFilters  = out;
        return 2;
    }

    // First pass: count pipe-delimited tokens
    int32_t     tokCount = 1;
    const char *cp       = filter;

    while (*cp) {
        if (*cp == '|') {
            tokCount++;
        }

        cp++;
    }

    FileFilterT *out = (FileFilterT *)calloc(tokCount, sizeof(FileFilterT));

    if (!out) {
        return 0;
    }

    snprintf(buf, bufSize, "%s", filter);
    int32_t count = 0;
    char   *p     = buf;

    while (*p) {
        char *pipe = strchr(p, '|');

        if (pipe) {
            *pipe = '\0';
        }

        out[count].label = p;
        count++;
        p = pipe ? pipe + 1 : p + strlen(p);
    }

    *outFilters = out;
    return count;
}


// Name of the form's popup menu that owns this MenuT, or "" (also for
// NULL, i.e. no context menu set).
static const char *popupMenuNameFor(const BasFormT *form, const MenuT *menu) {
    if (menu && form) {
        for (int32_t i = 0; i < form->popupMenuCount; i++) {
            if (form->popupMenus[i].menu == menu) {
                return form->popupMenus[i].name;
            }
        }
    }

    return "";
}


// refreshDetailControls -- cascade master-detail: refresh detail Data controls
static void refreshDetailControls(BasFormT *form, BasControlT *masterCtrl) {
    if (!masterCtrl->widget) {
        return;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(form->controls); i++) {
        BasControlT *ctrl = form->controls[i];

        // Never treat the master as its own detail: a control naming itself
        // in MasterSource would otherwise re-refresh itself and recurse.
        if (ctrl == masterCtrl) {
            continue;
        }

        if (strcasecmp(ctrl->typeName, "Data") != 0 || !ctrl->widget || !ctrl->iface) {
            continue;
        }

        // Read MasterSource and MasterField from the widget's interface
        const char *ms = ifaceGetStringProp(ctrl, "MasterSource");
        const char *mf = ifaceGetStringProp(ctrl, "MasterField");

        if (!ms || !ms[0] || strcasecmp(ms, masterCtrl->name) != 0) {
            continue;
        }

        // Get the master's current value for MasterField
        const char *val = "";

        if (mf && mf[0]) {
            val = wgtDataCtrlGetField(masterCtrl->widget, mf);
        }

        // Set the filter value and refresh the detail.  wgtDataCtrlRefresh
        // fires onChange, which already runs updateBoundControls for this
        // detail, so a manual follow-up call would double it.
        wgtDataCtrlSetMasterValue(ctrl->widget, val);
        wgtDataCtrlRefresh(ctrl->widget);
    }
}


// Detach a control and every control whose widget lives under it, then
// destroy the widget subtree.  Terminal bindings are dropped first (the
// idle pollers would otherwise dereference freed widgets), the entries
// leave form->controls immediately so name lookups miss them, and the
// BasControlT structs are freed only when no handler is on the stack
// (fireCtrlEventOut writes ctrl->eventFiring after the handler returns,
// and that handler may be the removed control's own).  Shared by
// RemoveControl and Toolbar.Clear.  The tooltip the compositor may be
// showing is hidden by wgtDestroy itself.
static void removeCtrlTree(BasFormRtT *rt, BasFormT *form, BasControlT *ctrl) {
    WidgetT *widget      = ctrl->widget;
    bool     serDetached = false;

    for (int32_t i = (int32_t)arrlen(form->controls) - 1; i >= 0; i--) {
        BasControlT *c = form->controls[i];

        if (c != ctrl && !(widget && c->widget && widgetHasAncestor(c->widget, widget))) {
            continue;
        }

        if (c->widget && formRtDetachTermsForWidget(c->widget)) {
            serDetached = true;
        }

        c->widget = NULL;
        arrdel(form->controls, i);

        if (rt->eventDepth > 0) {
            arrput(rt->pendingCtrlFree, c);
        } else {
            freeControl(c);
        }
    }

    if (serDetached) {
        formRtSerIdleSync();
    }

    wgtDestroy(widget);
}


int32_t ResAddFile(const char *path, const char *name, int32_t type, const char *srcFile) {
    if (!path || !name || !srcFile) {
        return 0;
    }

    int32_t fileSize = 0;
    uint8_t *buf     = (uint8_t *)platformReadFile(srcFile, &fileSize);

    if (!buf || fileSize <= 0) {
        free(buf);
        return 0;
    }

    int32_t result = dvxResAppend(path, name, (uint32_t)type, buf, (uint32_t)fileSize) == 0 ? -1 : 0;
    free(buf);
    return result;
}


int32_t ResAddText(const char *path, const char *name, const char *text) {
    if (!path || !name) {
        return 0;
    }

    if (!text) {
        text = "";
    }

    return dvxResAppend(path, name, DVX_RES_TEXT, text, (uint32_t)strlen(text) + 1) == 0 ? -1 : 0;
}


void ResClose(int32_t handle) {
    int32_t idx = handle - 1;

    if (idx < 0 || idx >= RES_MAX_HANDLES || !sResHandles[idx]) {
        return;
    }

    dvxResClose(sResHandles[idx]);
    sResHandles[idx] = NULL;
}


int32_t ResCount(int32_t handle) {
    int32_t idx = handle - 1;

    if (idx >= 0 && idx < RES_MAX_HANDLES && sResHandles[idx]) {
        return (int32_t)sResHandles[idx]->entryCount;
    }

    return 0;
}


int32_t ResExtract(const char *path, const char *name, const char *outFile) {
    if (!path || !name || !outFile) {
        return 0;
    }

    DvxResHandleT *h = dvxResOpen(path);

    if (!h) {
        return 0;
    }

    uint32_t size = 0;
    void    *data = dvxResRead(h, name, &size);
    dvxResClose(h);

    if (!data) {
        return 0;
    }

    FILE *f = fopen(outFile, "wb");

    if (!f) {
        free(data);
        return 0;
    }

    int32_t result = (fwrite(data, 1, size, f) == size) ? -1 : 0;
    fclose(f);
    free(data);
    return result;
}


const char *ResGetText(const char *path, const char *name) {
    static char sBuf[BAS_RES_TEXT_LEN];
    sBuf[0] = '\0';

    if (!path || !name) {
        return sBuf;
    }

    DvxResHandleT *h = dvxResOpen(path);

    if (!h) {
        return sBuf;
    }

    uint32_t size = 0;
    void    *data = dvxResRead(h, name, &size);
    dvxResClose(h);

    if (data) {
        uint32_t copyLen = size;

        if (copyLen >= sizeof(sBuf)) {
            copyLen = sizeof(sBuf) - 1;
        }

        memcpy(sBuf, data, copyLen);
        sBuf[copyLen] = '\0';
        free(data);
    }

    return sBuf;
}


// Widgets with a pixel buffer (Canvas, Image, PictureBox) expose
// Resize(w, h) so the buffer can follow the layout size once Width and
// Height are known.  Runs after .frm parsing and on every runtime
// Width/Height assignment.
static void resizeIfaceBuffer(BasControlT *ctrl) {
    WidgetT *w = ctrl->widget;

    if (!w || w->minW <= 0 || w->minH <= 0) {
        return;
    }

    const WgtMethodDescT *m = ifaceFindMethod(ctrl->iface, "Resize", WGT_SIG_INT_INT);

    if (m) {
        ((void (*)(WidgetT *, int32_t, int32_t))m->fn)(w, w->minW, w->minH);
    }
}


const char *ResName(int32_t handle, int32_t index) {
    int32_t idx = handle - 1;

    if (idx >= 0 && idx < RES_MAX_HANDLES && sResHandles[idx] &&
        index >= 0 && index < (int32_t)sResHandles[idx]->entryCount) {
        return sResHandles[idx]->entries[index].name;
    }

    return "";
}


// Resolve a menu item by command id across both the form's menu bar AND its
// popup-only menus, so .Checked/.Enabled work on Visible=False popup items
// (which have no menu-bar entry) as well as bar items.  When fromBar is
// non-NULL it is set true if the item was found on the menu bar, so callers
// can pick the bar radio-group setter over the popup one without re-walking.
static MenuItemT *resolveMenuItem(BasFormT *form, int32_t menuId, bool *fromBar) {
    if (fromBar) {
        *fromBar = false;
    }

    if (!form || menuId <= 0) {
        return NULL;
    }

    // Menu-bar items: walk every top-level menu (and its submenus) on the
    // form's window menu bar.
    if (form->window && form->window->menuBar) {
        MenuBarT *bar = form->window->menuBar;

        for (int32_t i = 0; i < bar->menuCount; i++) {
            MenuItemT *item = wmMenuFindItemInMenu(bar->menus[i], menuId);

            if (item) {
                if (fromBar) {
                    *fromBar = true;
                }

                return item;
            }
        }
    }

    // Popup-only items: search each named popup/context menu the form owns.
    for (int32_t i = 0; i < form->popupMenuCount; i++) {
        MenuItemT *item = wmMenuFindItemInMenu(form->popupMenus[i].menu, menuId);

        if (item) {
            return item;
        }
    }

    return NULL;
}


// 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 || !proc->formName[0]) {
        return NULL;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(rt->forms); i++) {
        if (strcasecmp(rt->forms[i]->name, proc->formName) == 0) {
            return rt->forms[i];
        }
    }

    return NULL;
}


// Lazily resolve the shell's idle-handler registry (mirrors the
// _shellLoadAppWithArgs resolution used by HelpView).  Returns false when
// the host does not export the registry -- e.g. a headless/proxy build with
// no shell event loop -- in which case polling is simply not driven.
static bool resolveShellIdle(void) {
    if (!sShellIdleResolved) {
        sShellIdleResolved   = true;
        sShellRegisterIdle   = dlsym(NULL, "_shellRegisterIdle");
        sShellUnregisterIdle = dlsym(NULL, "_shellUnregisterIdle");
    }

    return sShellRegisterIdle != NULL;
}


// Resolve a type name (VB-style or DVX widget name) to a DVX
// widget type name. First tries wgtFindByBasName for VB names
// like "CommandButton", then falls back to direct widget name.

const char *resolveTypeName(const char *typeName) {
    // Try VB name first (e.g. "CommandButton" -> "button")
    const char *wgtName = wgtFindByBasName(typeName);

    if (wgtName) {
        return wgtName;
    }

    // Try as direct widget type name (e.g. "button", "slider")
    if (wgtGetApi(typeName)) {
        return typeName;
    }

    return NULL;
}


int32_t ResOpen(const char *path) {
    if (!path) {
        return 0;
    }

    for (int32_t i = 0; i < RES_MAX_HANDLES; i++) {
        if (!sResHandles[i]) {
            sResHandles[i] = dvxResOpen(path);

            if (sResHandles[i]) {
                return i + 1;
            }

            return 0;
        }
    }

    return 0;
}


int32_t ResRemove(const char *path, const char *name) {
    if (!path || !name) {
        return 0;
    }

    return dvxResRemove(path, name) == 0 ? -1 : 0;
}


int32_t ResSize(int32_t handle, int32_t index) {
    int32_t idx = handle - 1;

    if (idx >= 0 && idx < RES_MAX_HANDLES && sResHandles[idx] &&
        index >= 0 && index < (int32_t)sResHandles[idx]->entryCount) {
        return (int32_t)sResHandles[idx]->entries[index].size;
    }

    return 0;
}


int32_t ResType(int32_t handle, int32_t index) {
    int32_t idx = handle - 1;

    if (idx >= 0 && idx < RES_MAX_HANDLES && sResHandles[idx] &&
        index >= 0 && index < (int32_t)sResHandles[idx]->entryCount) {
        return (int32_t)sResHandles[idx]->entries[index].type;
    }

    return 0;
}


// The single VM-entry wrapper for firing BASIC handler code.  Owns, in
// order: the prevForm/prevVars/prevVarCount save, the owning-form vars
// binding, the rtEventEnter/rtEventLeave pairing, and the restore --
// so the "restore before leave" ordering constraint lives in exactly
// one place.  proc may be NULL for raw code addresses with no
// BasProcEntryT (form-var init): the form-scope vars then bind to
// eventForm itself.  Explicit rtEventEnter/rtEventLeave brackets
// elsewhere in this file are the documented native-bridge pattern
// (rule N): they protect a native caller's own trailing reads/writes
// of form/control memory after a fire.
static bool rtCallHandler(BasFormRtT *rt, BasFormT *eventForm, const BasProcEntryT *proc, int32_t codeAddr, const BasValueT *args, int32_t argCount, BasValueT *outArgs, int32_t outArgCount) {
    BasFormT  *prevForm     = rt->currentForm;
    BasValueT *prevVars     = rt->vm->currentFormVars;
    int32_t    prevVarCount = rt->vm->currentFormVarCount;

    // Bind form-scope vars to the SUB's owning form (the .frm that
    // declared it inside BEGINFORM...ENDFORM), not to the event 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 : eventForm;

    rt->currentForm = eventForm;
    basVmSetCurrentForm(rt->vm, eventForm);
    basVmSetCurrentFormVars(rt->vm, varsForm ? varsForm->formVars : NULL, varsForm ? varsForm->formVarCount : 0);

    bool ok;

    rtEventEnter(rt);

    if (outArgs && outArgCount > 0) {
        ok = basVmCallSubWithArgsOut(rt->vm, codeAddr, args, argCount, outArgs, outArgCount);
    } else if (args && argCount > 0) {
        ok = basVmCallSubWithArgs(rt->vm, codeAddr, args, argCount);
    } else {
        ok = basVmCallSub(rt->vm, codeAddr);
    }

    rt->currentForm = prevForm;
    basVmSetCurrentForm(rt->vm, prevForm);
    basVmSetCurrentFormVars(rt->vm, prevVars, prevVarCount);
    rtEventLeave(rt);
    return ok;
}


// Tracks entry into a frame that holds BASIC form/control pointers: a
// VM event handler (via rtCallHandler) or a native bridge that reads
// them after firing (rule-N brackets).  While eventDepth > 0, form and
// control teardown detaches immediately but defers the frees.
static void rtEventEnter(BasFormRtT *rt) {
    rt->eventDepth++;
}


// Tracks exit from such a frame.  When the outermost frame returns,
// flushes every deferred free -- at this point no VM frame or native
// bridge references the queued form vars, controls, or BasFormT.
// rtCallHandler enforces the restore-before-leave ordering for VM
// entries; native-bridge brackets close after their trailing reads.
static void rtEventLeave(BasFormRtT *rt) {
    rt->eventDepth--;

    if (rt->eventDepth > 0) {
        return;
    }

    if (arrlen(rt->pendingCtrlFree) == 0 && arrlen(rt->pendingUnload) == 0) {
        return;
    }

    // Bump the DVX dispatch depth around the drain so any window destroy
    // reached from unloadFormNow always DEFERS: this flush can run under
    // a native widget frame at dispatchDepth 0 (e.g. a Data-control
    // method fired a Reposition handler from Sub Main), and a
    // synchronous wmDestroyWindow there would free the very widget that
    // frame is still executing on.  The deferred windows are inert and
    // the next dvxUpdate's depth-0 flag scan reclaims them.
    rt->ctx->dispatchDepth++;

    // Pop-before-call so nested re-entrant drains (e.g. an Activate
    // fired by the successor focus of a destroyed window) converge:
    // whichever drain runs first consumes the entry exactly once.
    while (arrlen(rt->pendingCtrlFree) > 0) {
        BasControlT *ctrl = arrpop(rt->pendingCtrlFree);

        freeControl(ctrl);
    }

    while (arrlen(rt->pendingUnload) > 0) {
        BasFormT *form = arrpop(rt->pendingUnload);

        unloadFormNow(rt, form);
    }

    rt->ctx->dispatchDepth--;
}


void SerAttach(int32_t com, const char *termCtrlName) {
    if (!serResolveApi() || !sFormRt || !termCtrlName) {
        return;
    }

    int32_t idx = com - 1;

    if (idx < 0 || idx >= RS232_NUM_PORTS) {
        return;
    }

    // Find the terminal widget by control name
    WidgetT *termWidget = findCtrlWidgetByName(termCtrlName);

    if (!termWidget) {
        return;
    }

    // One binding per terminal and per port: unbind whatever this
    // terminal was draining and whatever terminal this port was feeding.
    formRtDetachTermsForWidget(termWidget);

    if (sSerAttach[idx].attached) {
        wgtAnsiTermSetComm(sSerAttach[idx].term, NULL, NULL, NULL);
    }

    sSerAttach[idx].com      = idx;
    sSerAttach[idx].term     = termWidget;
    sSerAttach[idx].attached = true;

    wgtAnsiTermSetComm(termWidget, &sSerAttach[idx], serTermRead, serTermWrite);

    // Register the serial poller with the shell's idle registry so this
    // terminal is fed every idle frame regardless of foreground app.
    formRtSerIdleSync();
}


int32_t SerAvailable(int32_t com) {
    if (!serResolveApi() || !sSerApi.getRxBuffered) {
        return 0;
    }

    return sSerApi.getRxBuffered(com - 1);
}


void SerClose(int32_t com) {
    if (!serResolveApi()) {
        return;
    }

    // Detach terminal if attached
    int32_t idx = com - 1;

    if (idx >= 0 && idx < RS232_NUM_PORTS && sSerAttach[idx].attached) {
        wgtAnsiTermSetComm(sSerAttach[idx].term, NULL, NULL, NULL);
        sSerAttach[idx].attached = false;
        sSerAttach[idx].term     = NULL;
        formRtSerIdleSync();
    }

    sSerApi.close(com - 1);
}


void SerDetach(int32_t com) {
    int32_t idx = com - 1;

    if (idx < 0 || idx >= RS232_NUM_PORTS || !sSerAttach[idx].attached) {
        return;
    }

    wgtAnsiTermSetComm(sSerAttach[idx].term, NULL, NULL, NULL);
    sSerAttach[idx].attached = false;
    sSerAttach[idx].term     = NULL;
    formRtSerIdleSync();
}


void SerFlush(int32_t com) {
    if (!serResolveApi() || !sSerApi.clearRxBuffer) {
        return;
    }

    sSerApi.clearRxBuffer(com - 1);
}


int32_t SerGetBase(int32_t com) {
    if (!serResolveApi() || !sSerApi.getBase) {
        return 0;
    }

    return sSerApi.getBase(com - 1);
}


int32_t SerGetIrq(int32_t com) {
    if (!serResolveApi() || !sSerApi.getIrq) {
        return 0;
    }

    return sSerApi.getIrq(com - 1);
}


int32_t SerGetUart(int32_t com) {
    if (!serResolveApi() || !sSerApi.getUartType) {
        return 0;
    }

    return sSerApi.getUartType(com - 1);
}


// Idle callback for raw serial: polls all attached ports
static void serIdlePoll(void *ctx) {
    (void)ctx;

    for (int32_t i = 0; i < RS232_NUM_PORTS; i++) {
        if (sSerAttach[i].attached && sSerAttach[i].term) {
            wgtAnsiTermPoll(sSerAttach[i].term);
        }
    }
}


int32_t SerOpen(int32_t com, int32_t baud, int32_t dataBits, const char *parity, int32_t stopBits, int32_t handshake) {
    if (!serResolveApi()) {
        return 0;
    }

    char parityChar = (parity && parity[0]) ? parity[0] : 'N';
    int  rc = sSerApi.open(com - 1, baud, dataBits, parityChar, stopBits, handshake);
    return (rc == 0) ? -1 : 0;
}


const char *SerRead(int32_t com) {
    if (!serResolveApi()) {
        return "";
    }

    static char buf[SER_RECV_BUF];
    int32_t n = sSerApi.read(com - 1, buf, (int)(sizeof(buf) - 1));

    if (n <= 0) {
        return "";
    }

    buf[n] = '\0';
    return buf;
}


static bool serResolveApi(void) {
    if (sSerApiResolved) {
        return sSerApi.open != NULL;
    }

    sSerApiResolved = true;
    sSerApi.open          = dlsym(NULL, "_rs232Open");
    sSerApi.close         = dlsym(NULL, "_rs232Close");
    sSerApi.read          = dlsym(NULL, "_rs232Read");
    sSerApi.write         = dlsym(NULL, "_rs232Write");
    sSerApi.writeBuf      = dlsym(NULL, "_rs232WriteBuf");
    sSerApi.getRxBuffered = dlsym(NULL, "_rs232GetRxBuffered");
    sSerApi.clearRxBuffer = dlsym(NULL, "_rs232ClearRxBuffer");
    sSerApi.getUartType   = dlsym(NULL, "_rs232GetUartType");
    sSerApi.getBase       = dlsym(NULL, "_rs232GetBase");
    sSerApi.getIrq        = dlsym(NULL, "_rs232GetIrq");
    sSerApi.setBase       = dlsym(NULL, "_rs232SetBase");
    sSerApi.setIrq        = dlsym(NULL, "_rs232SetIrq");

    return sSerApi.open != NULL;
}


void SerSetBase(int32_t com, int32_t base) {
    if (!serResolveApi() || !sSerApi.setBase) {
        return;
    }

    sSerApi.setBase(com - 1, base);
}


void SerSetIrq(int32_t com, int32_t irq) {
    if (!serResolveApi() || !sSerApi.setIrq) {
        return;
    }

    sSerApi.setIrq(com - 1, irq);
}


// Raw serial terminal callbacks
static int32_t serTermRead(void *ctx, uint8_t *buf, int32_t maxLen) {
    SerAttachT *sa = (SerAttachT *)ctx;

    if (!sSerApi.read) {
        return 0;
    }

    return sSerApi.read(sa->com, (char *)buf, maxLen);
}


static int32_t serTermWrite(void *ctx, const uint8_t *data, int32_t len) {
    SerAttachT *sa = (SerAttachT *)ctx;

    if (!sSerApi.writeBuf) {
        return 0;
    }

    int rc = sSerApi.writeBuf(sa->com, (const char *)data, len);
    return (rc >= 0) ? len : 0;
}


int32_t SerWrite(int32_t com, const char *data) {
    if (!serResolveApi() || !data) {
        return 0;
    }

    int32_t len = (int32_t)strlen(data);

    if (len == 0) {
        return -1;
    }

    int rc = sSerApi.writeBuf(com - 1, data, len);
    return (rc >= 0) ? -1 : 0;
}


static bool setCommonProp(BasControlT *ctrl, const char *propName, BasValueT value) {
    const BasPropDescT *pd = basFormRtFindCommonProp(propName);
    WidgetT            *w  = ctrl->widget;

    if (!pd) {
        return false;
    }

    switch ((BasCtrlPropE)(pd - sCtrlProps)) {
        case CTRL_PROP_LEFT:
            w->x = (int32_t)basValToNumber(value);
            wgtInvalidate(w);
            break;

        case CTRL_PROP_TOP:
            w->y = (int32_t)basValToNumber(value);
            wgtInvalidate(w);
            break;

        case CTRL_PROP_WIDTH:
        case CTRL_PROP_MINWIDTH:
            w->minW = wgtPixels((int32_t)basValToNumber(value));
            wgtInvalidate(w);
            resizeIfaceBuffer(ctrl);
            break;

        case CTRL_PROP_HEIGHT:
        case CTRL_PROP_MINHEIGHT:
            w->minH = wgtPixels((int32_t)basValToNumber(value));
            wgtInvalidate(w);
            resizeIfaceBuffer(ctrl);
            break;

        case CTRL_PROP_MAXWIDTH: {
            int32_t mw = (int32_t)basValToNumber(value);
            w->maxW = mw > 0 ? wgtPixels(mw) : 0;
            wgtInvalidate(w);
            break;
        }

        case CTRL_PROP_MAXHEIGHT: {
            int32_t mh = (int32_t)basValToNumber(value);
            w->maxH = mh > 0 ? wgtPixels(mh) : 0;
            wgtInvalidate(w);
            break;
        }

        case CTRL_PROP_WEIGHT:
            w->weight = (int32_t)basValToNumber(value);
            wgtInvalidate(w);
            break;

        case CTRL_PROP_VISIBLE:
            wgtSetVisible(w, basValIsTruthy(value));
            break;

        case CTRL_PROP_ENABLED:
            wgtSetEnabled(w, basValIsTruthy(value));
            break;

        case CTRL_PROP_READONLY:
            wgtSetReadOnly(w, basValIsTruthy(value));
            break;

        case CTRL_PROP_BACKCOLOR:
            w->bgColor = (uint32_t)(int32_t)basValToNumber(value);
            wgtInvalidatePaint(w);
            break;

        case CTRL_PROP_FORECOLOR:
            w->fgColor = (uint32_t)(int32_t)basValToNumber(value);
            wgtInvalidatePaint(w);
            break;

        case CTRL_PROP_CAPTION:
        case CTRL_PROP_TEXT: {
            // Widgets strdup their text internally.
            BasStringT *s = basValFormatString(value);
            wgtSetText(w, s->data);
            basStringUnref(s);
            break;
        }

        case CTRL_PROP_TOOLTIPTEXT: {
            // wgtSetTooltip references ctrl->tooltip, which must outlive
            // the widget (freed by freeControl).
            BasStringT *s = basValFormatString(value);
            free(ctrl->tooltip);
            ctrl->tooltip = (s->len > 0) ? strdup(s->data) : NULL;
            wgtSetTooltip(w, ctrl->tooltip);
            basStringUnref(s);
            break;
        }

        case CTRL_PROP_CONTEXTMENU: {
            // Set the auto-right-click menu.  Empty string clears it.
            BasStringT       *s  = basValFormatString(value);
            BasFrmPopupMenuT *pm = (s->len > 0) ? findPopupMenu(ctrl->form, s->data) : NULL;

            w->contextMenu = pm ? pm->menu : NULL;
            basStringUnref(s);
            break;
        }

        case CTRL_PROP_HELPTOPIC:
            setControlString(ctrl->helpTopic, sizeof(ctrl->helpTopic), value);
            break;

        case CTRL_PROP_DATASOURCE:
            setControlString(ctrl->dataSource, sizeof(ctrl->dataSource), value);
            break;

        case CTRL_PROP_DATAFIELD:
            setControlString(ctrl->dataField, sizeof(ctrl->dataField), value);
            break;

        case CTRL_PROP_NAME:
        case CTRL_PROP_LISTCOUNT:
        case CTRL_PROP_COUNT:
            // Read-only: let basFormRtSetProp report it.
            return false;
    }

    return true;
}


// Store a BASIC value's text into a fixed-size BasControlT string field.
static void setControlString(char *dst, int32_t dstSize, BasValueT value) {
    BasStringT *s = basValFormatString(value);
    snprintf(dst, dstSize, "%s", s->data);
    basStringUnref(s);
}


// FormName.Prop = value: window geometry/state plus the BasFormT flags.
static void setFormProp(BasFormRtT *rt, BasFormT *frm, const char *propName, BasValueT value) {
    WindowT            *win = frm->window;
    const BasPropDescT *pd  = basFormRtFindFormProp(propName);

    if (!pd || !pd->writable) {
        char valid[BAS_PROP_LIST_LEN];
        formatPropNames(sFormProps, FORM_PROP_COUNT, true, valid, sizeof(valid));
        basFormRtRuntimeError(rt,
            "Unknown form property",
            "Form: %s\nProperty: %s\nValid: %s.",
            frm->name, propName, valid);
        return;
    }

    // Geometry writes need the window; a windowless form has nothing to
    // move (every path below that touches win is guarded by this).
    bool    hasWin = (win != NULL);
    int32_t num    = (int32_t)basValToNumber(value);
    bool    flag   = basValIsTruthy(value);

    switch ((BasFormPropE)(pd - sFormProps)) {
        case FORM_PROP_CAPTION:
            if (hasWin) {
                BasStringT *s = basValFormatString(value);
                dvxSetTitle(rt->ctx, win, s->data);
                basStringUnref(s);
            }
            break;

        case FORM_PROP_VISIBLE:
            if (hasWin) {
                if (flag) {
                    dvxShowWindow(rt->ctx, win);
                } else {
                    dvxHideWindow(rt->ctx, win);
                }
            }
            break;

        case FORM_PROP_WIDTH:
            if (hasWin) {
                dvxResizeWindow(rt->ctx, win, num, win->h);
            }
            break;

        case FORM_PROP_HEIGHT:
            if (hasWin) {
                dvxResizeWindow(rt->ctx, win, win->w, num);
            }
            break;

        case FORM_PROP_LEFT:
            if (hasWin) {
                dirtyListAdd(&rt->ctx->dirty, win->x, win->y, win->w, win->h);
                win->x = num;
                dirtyListAdd(&rt->ctx->dirty, win->x, win->y, win->w, win->h);
            }
            break;

        case FORM_PROP_TOP:
            if (hasWin) {
                dirtyListAdd(&rt->ctx->dirty, win->x, win->y, win->w, win->h);
                win->y = num;
                dirtyListAdd(&rt->ctx->dirty, win->x, win->y, win->w, win->h);
            }
            break;

        case FORM_PROP_RESIZABLE:
            if (hasWin) {
                dirtyListAdd(&rt->ctx->dirty, win->x, win->y, win->w, win->h);
                win->resizable = flag;
                dirtyListAdd(&rt->ctx->dirty, win->x, win->y, win->w, win->h);
            }
            break;

        case FORM_PROP_AUTOSIZE:
            frm->frmAutoSize = flag;

            if (flag && hasWin) {
                dvxFitWindow(rt->ctx, win);
            }
            break;

        case FORM_PROP_CENTERED:
            frm->frmCentered = flag;

            if (flag && hasWin) {
                dirtyListAdd(&rt->ctx->dirty, win->x, win->y, win->w, win->h);
                win->x = (rt->ctx->display.width - win->w) / 2;
                win->y = (rt->ctx->display.height - win->h) / 2;
                dirtyListAdd(&rt->ctx->dirty, win->x, win->y, win->w, win->h);
            }
            break;

        case FORM_PROP_CONTEXTMENU: {
            // Attach to the window so right-clicks outside any child
            // widget still pop the menu.  Empty string clears it.
            BasStringT       *s  = basValFormatString(value);
            BasFrmPopupMenuT *pm = (s->len > 0) ? findPopupMenu(frm, s->data) : NULL;

            if (hasWin) {
                win->contextMenu = pm ? pm->menu : NULL;
            }

            basStringUnref(s);
            break;
        }

        case FORM_PROP_HELPTOPIC:
            setControlString(frm->helpTopic, sizeof(frm->helpTopic), value);
            break;

        case FORM_PROP_NAME:
        case FORM_PROP_LAYOUT:
        case FORM_PROP_COUNT:
            // Read-only: rejected by the writable check above.
            break;
    }
}


// Look up a property in the interface descriptor and call its
// setter, marshaling the BasValueT to the native type.

static bool setIfaceProp(const WgtIfaceT *iface, WidgetT *w, const char *propName, BasValueT value) {
    const WgtPropDescT *p = wgtIfaceFindProp(iface, propName);

    if (!p) {
        return false;
    }

    if (!p->setFn) {
        return true; // read-only, but recognized
    }

    switch (p->type) {
        case WGT_IFACE_STRING: {
            BasStringT *s = basValFormatString(value);
            ((void (*)(WidgetT *, const char *))p->setFn)(w, s->data);
            basStringUnref(s);
            break;
        }

        case WGT_IFACE_ENUM:
            if (value.type == BAS_TYPE_STRING && value.strVal) {
                // Map name to index; an unknown name is a program bug,
                // not a silent zero.
                int32_t enumVal = ifaceEnumIndex(p, value.strVal->data);

                if (enumVal < 0) {
                    basFormRtRuntimeError(sFormRt,
                        "Bad enum value for property",
                        "Property: %s\nValue: %s\nNot one of this property's named values.",
                        p->name, value.strVal->data);
                    break;
                }

                ((void (*)(WidgetT *, int32_t))p->setFn)(w, enumVal);
            } else {
                ((void (*)(WidgetT *, int32_t))p->setFn)(w, (int32_t)basValToNumber(value));
            }
            break;

        case WGT_IFACE_INT:
            ((void (*)(WidgetT *, int32_t))p->setFn)(w, (int32_t)basValToNumber(value));
            break;

        case WGT_IFACE_BOOL:
            ((void (*)(WidgetT *, bool))p->setFn)(w, basValIsTruthy(value));
            break;

        case WGT_IFACE_FLOAT:
            ((void (*)(WidgetT *, float))p->setFn)(w, (float)basValToNumber(value));
            break;
    }

    return true;
}


int32_t SQLAffected(int32_t db) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlAffectedRows) {
        return 0;
    }

    return sFormRt->vm->sql.sqlAffectedRows(db);
}


void SQLClose(int32_t db) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlClose) {
        return;
    }

    sFormRt->vm->sql.sqlClose(db);
}


int32_t SQLEof(int32_t rs) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlEof) {
        return -1;
    }

    return sFormRt->vm->sql.sqlEof(rs) ? -1 : 0;
}


const char *SQLError(int32_t db) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlError) {
        return "";
    }

    const char *err = sFormRt->vm->sql.sqlError(db);
    return err ? err : "";
}


int32_t SQLExec(int32_t db, const char *sql) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlExec) {
        return 0;
    }

    return sFormRt->vm->sql.sqlExec(db, sql) ? -1 : 0;
}


const char *SQLField(int32_t rs, const char *colName) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlFieldByName) {
        return "";
    }

    const char *text = sFormRt->vm->sql.sqlFieldByName(rs, colName);
    return text ? text : "";
}


int32_t SQLFieldCount(int32_t rs) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlFieldCount) {
        return 0;
    }

    return sFormRt->vm->sql.sqlFieldCount(rs);
}


double SQLFieldDbl(int32_t rs, int32_t col) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlFieldDbl) {
        return 0.0;
    }

    return sFormRt->vm->sql.sqlFieldDbl(rs, col);
}


int32_t SQLFieldInt(int32_t rs, int32_t col) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlFieldInt) {
        return 0;
    }

    return sFormRt->vm->sql.sqlFieldInt(rs, col);
}


const char *SQLFieldName(int32_t rs, int32_t col) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlFieldName) {
        return "";
    }

    const char *name = sFormRt->vm->sql.sqlFieldName(rs, col);
    return name ? name : "";
}


const char *SQLFieldText(int32_t rs, int32_t col) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlFieldText) {
        return "";
    }

    const char *text = sFormRt->vm->sql.sqlFieldText(rs, col);
    return text ? text : "";
}


void SQLFreeResult(int32_t rs) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlFreeResult) {
        return;
    }

    sFormRt->vm->sql.sqlFreeResult(rs);
}


int32_t SQLNext(int32_t rs) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlNext) {
        return 0;
    }

    return sFormRt->vm->sql.sqlNext(rs) ? -1 : 0;
}


int32_t SQLOpen(const char *path) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlOpen) {
        return 0;
    }

    return sFormRt->vm->sql.sqlOpen(path);
}


int32_t SQLQuery(int32_t db, const char *sql) {
    if (!sFormRt || !sFormRt->vm || !sFormRt->vm->sql.sqlQuery) {
        return 0;
    }

    return sFormRt->vm->sql.sqlQuery(db, sql);
}


// The free half of basFormRtUnloadForm: frees the form's controls,
// variables, window, and the BasFormT itself.  QueryUnload/Unload have
// already fired and detachFormForUnload has already removed the form
// from rt->forms by the time this runs.  Called directly when no frame
// holds form pointers, or from rtEventLeave's deferred-unload flush.
static void unloadFormNow(BasFormRtT *rt, BasFormT *form) {
    // Clear dangling implicit-form state BEFORE teardown: OP_LOAD_FORM
    // leaves vm->currentForm pointing at the loaded form, and an
    // unqualified control reference after the unload would otherwise
    // dereference freed memory through it.
    if (rt->currentForm == form) {
        rt->currentForm = NULL;
    }

    if (rt->vm && rt->vm->currentForm == form) {
        basVmSetCurrentForm(rt->vm, NULL);
    }

    if (rt->vm && form->formVars && rt->vm->currentFormVars == form->formVars) {
        basVmSetCurrentFormVars(rt->vm, NULL, 0);
    }

    destroyFormNow(rt, form);
}


static void updateBoundControls(BasFormT *form, BasControlT *dataCtrl) {
    if (!dataCtrl->widget) {
        return;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(form->controls); i++) {
        BasControlT *ctrl = form->controls[i];

        if (!ctrl->dataSource[0] || strcasecmp(ctrl->dataSource, dataCtrl->name) != 0) {
            continue;
        }

        if (!ctrl->widget) {
            continue;
        }

        // DBGrid: bind to the Data control widget and refresh
        if (strcasecmp(ctrl->typeName, "DBGrid") == 0) {
            wgtDbGridSetDataWidget(ctrl->widget, dataCtrl->widget);
            wgtDbGridRefresh(ctrl->widget);
            continue;
        }

        // Text-based controls: set text from current record field
        if (ctrl->dataField[0]) {
            const char *val = wgtDataCtrlGetField(dataCtrl->widget, ctrl->dataField);

            if (val) {
                wgtSetText(ctrl->widget, val);
            }
        }
    }
}


bool wgtApplyPropFromString(WidgetT *w, const WgtPropDescT *p, const char *val) {
    if (!w || !p || !p->setFn || !val) {
        return false;
    }

    if (p->type == WGT_IFACE_ENUM) {
        int32_t en = ifaceEnumIndex(p, val);

        if (en < 0) {
            return false;
        }

        ((void (*)(WidgetT *, int32_t))p->setFn)(w, en);
        return true;
    }

    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;
}


// True if w is ancestor or sits anywhere beneath it.
static bool widgetHasAncestor(const WidgetT *w, const WidgetT *ancestor) {
    for (const WidgetT *p = w; p; p = p->parent) {
        if (p == ancestor) {
            return true;
        }
    }

    return false;
}


// Wires the full set of widget event callbacks to the runtime dispatchers and
// links the widget back to its BasControlT.  Shared by the two dynamic
// create-control paths and the .frm loader so every control behaves the same.
static void wireWidgetEvents(WidgetT *w, BasControlT *ctrl) {
    w->userData    = ctrl;
    w->onClick     = onWidgetClick;
    w->onDblClick  = onWidgetDblClick;
    w->onChange    = onWidgetChange;
    w->onFocus     = onWidgetFocus;
    w->onBlur      = onWidgetBlur;
    w->onValidate  = onWidgetValidate;
    w->onKeyPress  = onWidgetKeyPress;
    w->onKeyDown   = onWidgetKeyDown;
    w->onKeyUp     = onWidgetKeyUp;
    w->onMouseDown = onWidgetMouseDown;
    w->onMouseUp   = onWidgetMouseUp;
    w->onMouseMove = onWidgetMouseMove;
    w->onScroll    = onWidgetScroll;
}


static BasValueT zeroValue(void) {
    BasValueT v;
    memset(&v, 0, sizeof(v));
    return v;
}
