From 2c170d263ff9af50931c1bd530b2d5c7eb514cb4 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Mon, 31 Aug 2026 14:15:53 -0500 Subject: [PATCH] Many fixes and greatly increased test coverage. --- .gitignore | 1 + Makefile | 44 +- docs/dvx_basic_reference.html | 110 +- src/apps/kpunch/Makefile | 56 + src/apps/kpunch/cpanel/cpanel.c | 60 +- src/apps/kpunch/dvxbasic/Makefile | 120 +- src/apps/kpunch/dvxbasic/basBuild.c | 166 + src/apps/kpunch/dvxbasic/basBuild.h | 36 + src/apps/kpunch/dvxbasic/compiler/codegen.c | 5 - src/apps/kpunch/dvxbasic/compiler/codegen.h | 3 +- src/apps/kpunch/dvxbasic/compiler/compact.c | 30 +- src/apps/kpunch/dvxbasic/compiler/lexer.c | 2 + src/apps/kpunch/dvxbasic/compiler/lexer.h | 2 + src/apps/kpunch/dvxbasic/compiler/opcodes.h | 69 +- src/apps/kpunch/dvxbasic/compiler/parser.c | 1048 +++-- src/apps/kpunch/dvxbasic/compiler/parser.h | 1 + src/apps/kpunch/dvxbasic/compiler/symtab.h | 2 + .../kpunch/dvxbasic/formrt/basNativeDos.c | 90 + .../kpunch/dvxbasic/formrt/basNativeHost.c | 41 + src/apps/kpunch/dvxbasic/formrt/formrt.c | 298 +- src/apps/kpunch/dvxbasic/formrt/formrt.h | 30 +- src/apps/kpunch/dvxbasic/help.dhs | 1 + src/apps/kpunch/dvxbasic/ide/ideDesigner.h | 2 +- src/apps/kpunch/dvxbasic/ide/ideMain.c | 249 +- src/apps/kpunch/dvxbasic/ide/ideProject.h | 12 +- src/apps/kpunch/dvxbasic/ide/ideProperties.c | 24 +- src/apps/kpunch/dvxbasic/langref.dhs | 114 +- src/apps/kpunch/dvxbasic/runtime/basErrors.h | 1 + src/apps/kpunch/dvxbasic/runtime/values.c | 180 +- src/apps/kpunch/dvxbasic/runtime/values.h | 10 +- src/apps/kpunch/dvxbasic/runtime/vm.c | 1490 ++++-- src/apps/kpunch/dvxbasic/runtime/vm.h | 11 +- src/apps/kpunch/dvxbasic/stub/bascomp.c | 221 +- src/apps/kpunch/dvxbasic/stub/basrun.c | 400 ++ src/apps/kpunch/dvxbasic/test_compact.c | 202 +- src/apps/kpunch/dvxbasic/test_compiler.c | 1357 +++++- src/apps/kpunch/dvxbasic/test_quick.c | 85 - src/apps/kpunch/dvxbasic/test_suite.c | 4097 +++++++++++++--- src/apps/kpunch/dvxbasic/test_vm.c | 242 - src/apps/kpunch/dvxbasic/ts_b.tmp | 0 src/apps/kpunch/dvxhelp/dvxhelp.c | 16 +- src/apps/kpunch/progman/progman.c | 42 +- src/libs/kpunch/dvxshell/shellApp.c | 42 +- src/libs/kpunch/dvxshell/shellApp.h | 7 + src/libs/kpunch/dvxshell/shellMain.c | 200 +- src/libs/kpunch/libdvx/Makefile | 37 +- src/libs/kpunch/libdvx/dvxApp.c | 3490 +++++++------- src/libs/kpunch/libdvx/dvxApp.h | 21 +- src/libs/kpunch/libdvx/dvxDialog.c | 327 +- src/libs/kpunch/libdvx/dvxDraw.c | 118 +- src/libs/kpunch/libdvx/dvxMem.h | 6 +- src/libs/kpunch/libdvx/dvxPal.h | 7 +- src/libs/kpunch/libdvx/dvxPrefs.c | 43 +- src/libs/kpunch/libdvx/dvxWgt.h | 216 +- src/libs/kpunch/libdvx/dvxWgtP.h | 47 +- src/libs/kpunch/libdvx/dvxWm.c | 176 +- src/libs/kpunch/libdvx/platform/dvxMemTrack.c | 255 + src/libs/kpunch/libdvx/platform/dvxPlat.h | 63 +- src/libs/kpunch/libdvx/platform/dvxPlatHost.h | 100 + .../kpunch/libdvx/platform/dvxPlatformDos.c | 254 +- .../kpunch/libdvx/platform/dvxPlatformHost.c | 857 ++++ .../kpunch/libdvx/platform/dvxPlatformUtil.c | 306 +- src/libs/kpunch/libdvx/widgetCore.c | 42 +- src/libs/kpunch/libdvx/widgetEvent.c | 41 +- src/libs/kpunch/libdvx/widgetLayout.c | 131 +- src/libs/kpunch/libdvx/widgetOps.c | 22 +- src/libs/kpunch/libtasks/Makefile | 30 +- src/libs/kpunch/libtasks/taskswitch.c | 87 +- src/libs/kpunch/listhelp/listHelp.c | 2 +- src/libs/kpunch/serial/rs232/rs232.c | 85 +- src/libs/kpunch/serial/seclink/secLink.c | 23 +- src/libs/kpunch/serial/security/security.c | 12 +- src/libs/kpunch/serial/security/security.h | 4 +- src/libs/kpunch/sql/Makefile | 60 +- src/libs/kpunch/texthelp/textHelp.c | 273 +- src/libs/kpunch/texthelp/textHelp.h | 22 +- src/loader/Makefile | 10 +- src/loader/loaderMain.c | 107 +- src/test/basic/Makefile | 124 + src/test/basic/extractCorpus.py | 226 + src/test/basic/fixtures/arraysMulti.bas | 35 + src/test/basic/fixtures/arraysMulti.out | 10 + src/test/basic/fixtures/cintRounding.bas | 18 + src/test/basic/fixtures/cintRounding.out | 13 + src/test/basic/fixtures/compareText.bas | 8 + src/test/basic/fixtures/compareText.out | 2 + src/test/basic/fixtures/constsAndLet.bas | 13 + src/test/basic/fixtures/constsAndLet.out | 3 + src/test/basic/fixtures/dataRead.bas | 25 + src/test/basic/fixtures/dataRead.out | 5 + src/test/basic/fixtures/defTypes.bas | 15 + src/test/basic/fixtures/defTypes.out | 5 + src/test/basic/fixtures/divByZero.bas | 3 + src/test/basic/fixtures/divByZero.exit | 1 + src/test/basic/fixtures/divByZero.out | 1 + src/test/basic/fixtures/erlLine.bas | 11 + src/test/basic/fixtures/erlLine.out | 4 + src/test/basic/fixtures/fileBinary.bas | 38 + src/test/basic/fixtures/fileBinary.out | 8 + src/test/basic/fixtures/fileInputMulti.bas | 34 + src/test/basic/fixtures/fileInputMulti.out | 8 + src/test/basic/fixtures/fileRandom.bas | 41 + src/test/basic/fixtures/fileRandom.out | 5 + src/test/basic/fixtures/fileRandomUdt.bas | 53 + src/test/basic/fixtures/fileRandomUdt.out | 5 + src/test/basic/fixtures/fsDirOps.bas | 40 + src/test/basic/fixtures/fsDirOps.out | 11 + src/test/basic/fixtures/fsErrors.bas | 18 + src/test/basic/fixtures/fsErrors.out | 10 + src/test/basic/fixtures/gosub.bas | 34 + src/test/basic/fixtures/gosub.out | 4 + src/test/basic/fixtures/hexOct.bas | 8 + src/test/basic/fixtures/hexOct.out | 8 + src/test/basic/fixtures/iniReadWrite.bas | 13 + src/test/basic/fixtures/iniReadWrite.out | 7 + src/test/basic/fixtures/inputStdin.bas | 9 + src/test/basic/fixtures/inputStdin.in | 3 + src/test/basic/fixtures/inputStdin.out | 2 + src/test/basic/fixtures/intOverflow.bas | 5 + src/test/basic/fixtures/intOverflow.exit | 1 + src/test/basic/fixtures/intOverflow.out | 0 src/test/basic/fixtures/lineContinuation.bas | 11 + src/test/basic/fixtures/lineContinuation.out | 7 + src/test/basic/fixtures/loopsMisc.bas | 38 + src/test/basic/fixtures/loopsMisc.out | 8 + src/test/basic/fixtures/mathFuncs.bas | 18 + src/test/basic/fixtures/mathFuncs.out | 15 + src/test/basic/fixtures/noFormRuntime.bas | 13 + src/test/basic/fixtures/noFormRuntime.exit | 1 + src/test/basic/fixtures/noFormRuntime.out | 2 + src/test/basic/fixtures/onError.bas | 28 + src/test/basic/fixtures/onError.exit | 1 + src/test/basic/fixtures/onError.out | 9 + src/test/basic/fixtures/onErrorSub.bas | 31 + src/test/basic/fixtures/onErrorSub.out | 6 + src/test/basic/fixtures/optionBase.bas | 13 + src/test/basic/fixtures/optionBase.out | 2 + src/test/basic/fixtures/parserExt.bas | 57 + src/test/basic/fixtures/parserExt.out | 6 + src/test/basic/fixtures/printTab.bas | 13 + src/test/basic/fixtures/printTab.out | 12 + src/test/basic/fixtures/printUsing.bas | 17 + src/test/basic/fixtures/printUsing.out | 17 + .../basic/fixtures/printUsingLiterals.bas | 21 + .../basic/fixtures/printUsingLiterals.out | 14 + src/test/basic/fixtures/procs.bas | 27 + src/test/basic/fixtures/procs.out | 5 + src/test/basic/fixtures/resumeNoError.bas | 3 + src/test/basic/fixtures/resumeNoError.exit | 1 + src/test/basic/fixtures/resumeNoError.out | 1 + src/test/basic/fixtures/selectCase.bas | 39 + src/test/basic/fixtures/selectCase.out | 10 + src/test/basic/fixtures/staticVars.bas | 16 + src/test/basic/fixtures/staticVars.out | 4 + src/test/basic/fixtures/stepCap.args | 1 + src/test/basic/fixtures/stepCap.bas | 4 + src/test/basic/fixtures/stepCap.exit | 1 + src/test/basic/fixtures/stepCap.out | 0 src/test/basic/fixtures/stringCompare.bas | 7 + src/test/basic/fixtures/stringCompare.out | 5 + src/test/basic/fixtures/stringFuncs.bas | 26 + src/test/basic/fixtures/stringFuncs.out | 15 + src/test/basic/fixtures/swapVars.bas | 18 + src/test/basic/fixtures/swapVars.out | 3 + src/test/basic/fixtures/typedStore.bas | 45 + src/test/basic/fixtures/typedStore.out | 20 + src/test/basic/fixtures/typedStoreBoolean.bas | 19 + src/test/basic/fixtures/typedStoreBoolean.out | 6 + src/test/basic/fixtures/udtNested.bas | 41 + src/test/basic/fixtures/udtNested.out | 5 + src/test/basic/fuzzCommon.c | 266 ++ src/test/basic/fuzzCommon.h | 76 + src/test/basic/fuzzCompile.c | 392 ++ src/test/basic/fuzzModule.c | 486 ++ src/test/basic/runBasic.sh | 278 ++ src/test/coverage/Makefile | 44 + .../__pycache__/covReport.cpython-312.pyc | Bin 0 -> 13065 bytes src/test/coverage/covReport.py | 217 + src/test/coverage/runCoverage.sh | 150 + src/test/dialogs/Makefile | 49 + src/test/dialogs/dialogsTest.c | 1460 ++++++ src/test/dialogs/golden/msgBoxInfo32.png | 3 + src/test/dvxtest/Makefile | 49 + src/test/dvxtest/README.md | 103 + src/test/dvxtest/checkStatics.sh | 67 + src/test/dvxtest/dvxtest.c | 1393 ++++++ src/test/dvxtest/dvxtest.h | 241 + src/test/dvxtest/dvxtest.mk | 108 + src/test/dvxtest/golden/covTextAreaSel.png | 3 + src/test/dvxtest/golden/covTextInputSel.png | 3 + src/test/dvxtest/golden/selfButton.png | 3 + src/test/dvxtest/golden/selfWindow.png | 3 + src/test/dvxtest/selfTest.c | 453 ++ src/test/dvxtest/staticsAllowlist.txt | 10 + src/test/formrt/Makefile | 71 + src/test/formrt/dvx.log | 2638 +++++++++++ src/test/formrt/fixtures/icon.bmp | 3 + src/test/formrt/frtHarness.c | 567 +++ src/test/formrt/frtHarness.h | 100 + src/test/formrt/frtStubs.c | 571 +++ src/test/formrt/frtStubs.h | 86 + src/test/formrt/testFormRt.c | 4149 +++++++++++++++++ src/test/host/Makefile | 71 + src/test/host/smoke.c | 268 ++ src/test/ide/Makefile | 136 + src/test/ide/fixtures/fixture.dbp | 16 + src/test/ide/fixtures/form1.frm | 16 + src/test/ide/fixtures/main.bas | 7 + src/test/ide/ideStubs.c | 211 + src/test/ide/runCli.sh | 227 + src/test/ide/testIde.c | 1742 +++++++ src/test/ide/testIdeDebug.c | 773 +++ src/test/ide/testIdeDesign.c | 2912 ++++++++++++ src/test/ide/testStub.c | 277 ++ src/test/lint/Makefile | 29 + .../__pycache__/styleLint.cpython-312.pyc | Bin 0 -> 29825 bytes src/test/lint/analyzerBaseline.txt | 77 + src/test/lint/asciiBaseline.txt | 2 + src/test/lint/asciiCheck.sh | 79 + src/test/lint/baseline.txt | 767 +++ src/test/lint/cppcheckBaseline.txt | 59 + src/test/lint/staticGate.sh | 276 ++ src/test/lint/styleLint.py | 675 +++ src/test/samples/golden/basdemo-debug.app | Bin 0 -> 22198 bytes src/test/samples/golden/basdemo-release.app | Bin 0 -> 19168 bytes src/test/samples/golden/helpedit-debug.app | Bin 0 -> 11101 bytes src/test/samples/golden/helpedit-release.app | Bin 0 -> 9946 bytes src/test/samples/golden/iconed-debug.app | Bin 0 -> 14931 bytes src/test/samples/golden/iconed-release.app | Bin 0 -> 12512 bytes src/test/samples/golden/imgview-debug.app | Bin 0 -> 7663 bytes src/test/samples/golden/imgview-release.app | Bin 0 -> 7432 bytes src/test/samples/golden/inc-comm-debug.app | Bin 0 -> 6656 bytes src/test/samples/golden/inc-comm-release.app | Bin 0 -> 6574 bytes src/test/samples/golden/inc-commdlg-debug.app | Bin 0 -> 6487 bytes .../samples/golden/inc-commdlg-release.app | Bin 0 -> 6423 bytes src/test/samples/golden/inc-help-debug.app | Bin 0 -> 6394 bytes src/test/samples/golden/inc-help-release.app | Bin 0 -> 6339 bytes .../samples/golden/inc-resource-debug.app | Bin 0 -> 6499 bytes .../samples/golden/inc-resource-release.app | Bin 0 -> 6435 bytes src/test/samples/golden/inc-sql-debug.app | Bin 0 -> 6542 bytes src/test/samples/golden/inc-sql-release.app | Bin 0 -> 6487 bytes src/test/samples/golden/notepad-debug.app | Bin 0 -> 10065 bytes src/test/samples/golden/notepad-release.app | Bin 0 -> 9021 bytes src/test/samples/golden/resedit-debug.app | Bin 0 -> 12620 bytes src/test/samples/golden/resedit-release.app | Bin 0 -> 11062 bytes src/test/samples/golden/widshow-debug.app | Bin 0 -> 13890 bytes src/test/samples/golden/widshow-release.app | Bin 0 -> 12993 bytes src/test/samples/runSamples.sh | 147 + src/test/serial/Makefile | 139 + src/test/serial/loopShim.c | 435 ++ src/test/serial/loopShim.h | 112 + src/test/serial/testProxy.c | 1045 +++++ src/test/serial/testSerial.c | 1932 ++++++++ src/test/shell/Makefile | 106 + src/test/shell/appsTest.c | 1541 ++++++ src/test/shell/fixtures/fixBigStack.c | 48 + src/test/shell/fixtures/fixCrash.c | 47 + src/test/shell/fixtures/fixDecline.c | 42 + src/test/shell/fixtures/fixDesktop.c | 243 + src/test/shell/fixtures/fixEmpty.c | 37 + src/test/shell/fixtures/fixLoop.c | 49 + src/test/shell/fixtures/fixNoMain.c | 34 + src/test/shell/shellMainTest.c | 150 + src/test/shell/shellTest.c | 1015 ++++ src/test/shell/tasksTest.c | 653 +++ src/test/sql/Makefile | 58 + src/test/sql/sqlMain.c | 591 +++ src/test/tools/bmpDrawTest.c | 92 + src/test/tools/fixtures/bmp/splash.bmp | 3 + src/test/tools/fixtures/bmp/wrongSize.bmp | 3 + src/test/tools/fixtures/genFixtures.py | 88 + src/test/tools/fixtures/help/anim.gif | Bin 0 -> 30 bytes src/test/tools/fixtures/help/badDirective.dhs | 4 + src/test/tools/fixtures/help/brokenLink.dhs | 3 + src/test/tools/fixtures/help/extra.dhs | 11 + src/test/tools/fixtures/help/imageNoName.dhs | 3 + src/test/tools/fixtures/help/imgKinds.dhs | 6 + src/test/tools/fixtures/help/inc.dhs | 4 + .../tools/fixtures/help/includeNoFile.dhs | 3 + src/test/tools/fixtures/help/indexNoKey.dhs | 3 + src/test/tools/fixtures/help/inputs.txt | 3 + src/test/tools/fixtures/help/itemOutside.dhs | 4 + src/test/tools/fixtures/help/listEnd.dhs | 7 + src/test/tools/fixtures/help/main.dhs | 83 + src/test/tools/fixtures/help/missingImage.dhs | 3 + src/test/tools/fixtures/help/noteEnd.dhs | 6 + src/test/tools/fixtures/help/outsideTopic.dhs | 3 + src/test/tools/fixtures/help/photo.jpg | 3 + src/test/tools/fixtures/help/plain.xyz | Bin 0 -> 15 bytes src/test/tools/fixtures/help/sectionReset.dhs | 8 + src/test/tools/fixtures/help/selfInc.dhs | 3 + src/test/tools/fixtures/help/tiny.png | 3 + src/test/tools/fixtures/help/topicNoId.dhs | 1 + src/test/tools/fixtures/res/blob.bin | 2 + src/test/tools/fixtures/res/icon32.bmp | 3 + src/test/tools/fixtures/res/manifest.res | 9 + src/test/tools/fixtures/res/module.bin | Bin 0 -> 1500 bytes src/test/tools/fixtures/res/resBadDirOff.bin | Bin 0 -> 1516 bytes src/test/tools/fixtures/res/resDataShort.bin | Bin 0 -> 1564 bytes src/test/tools/fixtures/res/resHugeCount.bin | Bin 0 -> 1516 bytes src/test/tools/fixtures/res/resHugeSize.bin | Bin 0 -> 1564 bytes src/test/tools/fixtures/res/resShortDir.bin | Bin 0 -> 1516 bytes src/test/tools/golden/icons/mkicon_basic.bmp | 3 + src/test/tools/golden/icons/mkicon_clock.bmp | 3 + src/test/tools/golden/icons/mkicon_cpanel.bmp | 3 + .../tools/golden/icons/mkicon_dvxdemo.bmp | 3 + src/test/tools/golden/icons/mkicon_help.bmp | 3 + src/test/tools/golden/icons/mkicon_iconed.bmp | 3 + .../tools/golden/icons/mkicon_imgview.bmp | 3 + src/test/tools/golden/icons/mkicon_noicon.bmp | 3 + .../tools/golden/icons/mkicon_notepad.bmp | 3 + .../tools/golden/icons/mkicon_resedit.bmp | 3 + src/test/tools/golden/icons/mktbicon_code.bmp | 3 + .../tools/golden/icons/mktbicon_debug.bmp | 3 + .../tools/golden/icons/mktbicon_design.bmp | 3 + src/test/tools/golden/icons/mktbicon_open.bmp | 3 + src/test/tools/golden/icons/mktbicon_run.bmp | 3 + .../tools/golden/icons/mktbicon_runtocur.bmp | 3 + src/test/tools/golden/icons/mktbicon_save.bmp | 3 + .../tools/golden/icons/mktbicon_stepinto.bmp | 3 + .../tools/golden/icons/mktbicon_stepout.bmp | 3 + .../tools/golden/icons/mktbicon_stepover.bmp | 3 + src/test/tools/golden/icons/mktbicon_stop.bmp | 3 + .../tools/golden/icons/mkwgticon_button.bmp | 3 + .../tools/golden/icons/mkwgticon_canvas.bmp | 3 + .../tools/golden/icons/mkwgticon_checkbox.bmp | 3 + .../tools/golden/icons/mkwgticon_combobox.bmp | 3 + .../tools/golden/icons/mkwgticon_datactrl.bmp | 3 + .../tools/golden/icons/mkwgticon_dbgrid.bmp | 3 + .../tools/golden/icons/mkwgticon_dropdown.bmp | 3 + .../tools/golden/icons/mkwgticon_frame.bmp | 3 + .../tools/golden/icons/mkwgticon_hbox.bmp | 3 + .../tools/golden/icons/mkwgticon_image.bmp | 3 + .../tools/golden/icons/mkwgticon_imgbtn.bmp | 3 + .../tools/golden/icons/mkwgticon_label.bmp | 3 + .../tools/golden/icons/mkwgticon_listbox.bmp | 3 + .../tools/golden/icons/mkwgticon_listview.bmp | 3 + .../tools/golden/icons/mkwgticon_progress.bmp | 3 + .../tools/golden/icons/mkwgticon_radio.bmp | 3 + .../golden/icons/mkwgticon_scrollpane.bmp | 3 + .../golden/icons/mkwgticon_separator.bmp | 3 + .../tools/golden/icons/mkwgticon_slider.bmp | 3 + .../tools/golden/icons/mkwgticon_spacer.bmp | 3 + .../tools/golden/icons/mkwgticon_spinner.bmp | 3 + .../tools/golden/icons/mkwgticon_splitter.bmp | 3 + .../golden/icons/mkwgticon_statusbar.bmp | 3 + .../tools/golden/icons/mkwgticon_tabctrl.bmp | 3 + .../tools/golden/icons/mkwgticon_terminal.bmp | 3 + .../tools/golden/icons/mkwgticon_textbox.bmp | 3 + .../tools/golden/icons/mkwgticon_timer.bmp | 3 + .../tools/golden/icons/mkwgticon_toolbar.bmp | 3 + .../tools/golden/icons/mkwgticon_treeview.bmp | 3 + .../tools/golden/icons/mkwgticon_vbox.bmp | 3 + .../tools/golden/icons/mkwgticon_wrapbox.bmp | 3 + src/test/tools/golden/main.hlp | Bin 0 -> 3329 bytes src/test/tools/golden/main.html | 103 + src/test/tools/golden/resList1.txt | 11 + src/test/tools/golden/resList2.txt | 13 + src/test/tools/golden/splash.raw | Bin 0 -> 64768 bytes src/test/tools/hlpcLibTest.c | 138 + src/test/tools/resFailTest.c | 310 ++ src/test/tools/runTools.sh | 948 ++++ src/test/unit/Makefile | 79 + src/test/unit/testClock.c | 154 + src/test/unit/testComp.c | 373 ++ src/test/unit/testDraw.c | 1043 +++++ src/test/unit/testLayout.c | 543 +++ src/test/unit/testMem.c | 265 ++ src/test/unit/testPlatform.c | 309 ++ src/test/unit/testPrefs.c | 371 ++ src/test/unit/testResource.c | 403 ++ src/test/unit/testUtil.c | 520 +++ src/test/unit/testVideo.c | 276 ++ src/test/unit/testWm.c | 955 ++++ src/test/unit/unitDisplay.c | 90 + src/test/unit/unitMain.c | 153 + src/test/unit/unitTest.h | 85 + src/test/widgets/Makefile | 69 + .../test_lex.c => test/widgets/covMain.c} | 30 +- src/test/widgets/testCovListView.c | 830 ++++ src/test/widgets/testCovMisc.c | 816 ++++ src/test/widgets/testCovPopups.c | 548 +++ src/test/widgets/testCovTerm.c | 464 ++ src/test/widgets/testCovText.c | 1603 +++++++ src/test/widgets/testCovTreeView.c | 977 ++++ src/test/widgets/testData.c | 1260 +++++ src/test/widgets/testDestroy.c | 353 ++ src/test/widgets/testFocus.c | 297 ++ src/test/widgets/testHover.c | 158 + src/test/widgets/testInput.c | 624 +++ src/test/widgets/testLists.c | 746 +++ src/test/widgets/testMisc.c | 735 +++ src/test/widgets/testProps.c | 547 +++ src/test/widgets/testRange.c | 491 ++ src/test/widgets/widgetsMain.c | 49 + src/test/widgets/wtCommon.c | 259 + src/test/widgets/wtCommon.h | 96 + src/test/wm/Makefile | 58 + src/test/wm/wmSuite.c | 1873 ++++++++ src/test/wm/wmSuite2.c | 1920 ++++++++ src/tools/Makefile | 18 +- src/tools/bmp2raw.c | 4 +- src/tools/bmpDraw.c | 34 +- src/tools/dvxResWrite.h | 263 +- src/tools/dvxhlpc.c | 40 +- src/tools/dvxres.c | 42 +- src/tools/mkicon.c | 66 +- src/tools/mktbicon.c | 47 +- src/tools/mkwgticon.c | 113 +- src/tools/proxy/proxy.c | 3 + src/tools/proxy/stubs/pc.h | 12 +- src/tools/proxy/stubs/sys/farptr.h | 6 +- src/widgets/kpunch/Makefile | 36 +- src/widgets/kpunch/ansiTerm/widgetAnsiTerm.c | 750 ++- src/widgets/kpunch/box/widgetBox.c | 72 +- src/widgets/kpunch/checkbox/widgetCheckbox.c | 5 +- src/widgets/kpunch/comboBox/widgetComboBox.c | 40 +- src/widgets/kpunch/dataCtrl/widgetDataCtrl.c | 2 +- src/widgets/kpunch/dbGrid/widgetDbGrid.c | 222 +- src/widgets/kpunch/dropdown/widgetDropdown.c | 62 +- src/widgets/kpunch/image/widgetImage.c | 18 +- .../kpunch/imageButton/widgetImageButton.c | 15 +- src/widgets/kpunch/listBox/listBox.h | 5 + src/widgets/kpunch/listBox/widgetListBox.c | 66 +- src/widgets/kpunch/listView/widgetListView.c | 158 +- .../kpunch/progressBar/widgetProgressBar.c | 33 +- src/widgets/kpunch/radio/widgetRadio.c | 128 +- .../kpunch/scrollPane/widgetScrollPane.c | 15 +- .../kpunch/separator/widgetSeparator.c | 13 +- src/widgets/kpunch/slider/widgetSlider.c | 224 +- src/widgets/kpunch/spacer/widgetSpacer.c | 13 +- src/widgets/kpunch/spinner/widgetSpinner.c | 131 +- src/widgets/kpunch/splitter/widgetSplitter.c | 18 +- .../kpunch/statusBar/widgetStatusBar.c | 13 +- .../kpunch/tabControl/widgetTabControl.c | 81 +- .../kpunch/textInput/widgetTextInput.c | 85 +- src/widgets/kpunch/timer/timer.h | 1 + src/widgets/kpunch/timer/widgetTimer.c | 71 +- src/widgets/kpunch/toolbar/widgetToolbar.c | 26 +- src/widgets/kpunch/treeView/widgetTreeView.c | 100 +- 440 files changed, 70158 insertions(+), 6506 deletions(-) create mode 100644 src/apps/kpunch/dvxbasic/formrt/basNativeDos.c create mode 100644 src/apps/kpunch/dvxbasic/formrt/basNativeHost.c create mode 100644 src/apps/kpunch/dvxbasic/help.dhs create mode 100644 src/apps/kpunch/dvxbasic/stub/basrun.c delete mode 100644 src/apps/kpunch/dvxbasic/test_quick.c delete mode 100644 src/apps/kpunch/dvxbasic/test_vm.c create mode 100644 src/apps/kpunch/dvxbasic/ts_b.tmp create mode 100644 src/libs/kpunch/libdvx/platform/dvxMemTrack.c create mode 100644 src/libs/kpunch/libdvx/platform/dvxPlatHost.h create mode 100644 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c create mode 100644 src/test/basic/Makefile create mode 100755 src/test/basic/extractCorpus.py create mode 100644 src/test/basic/fixtures/arraysMulti.bas create mode 100644 src/test/basic/fixtures/arraysMulti.out create mode 100644 src/test/basic/fixtures/cintRounding.bas create mode 100644 src/test/basic/fixtures/cintRounding.out create mode 100644 src/test/basic/fixtures/compareText.bas create mode 100644 src/test/basic/fixtures/compareText.out create mode 100644 src/test/basic/fixtures/constsAndLet.bas create mode 100644 src/test/basic/fixtures/constsAndLet.out create mode 100644 src/test/basic/fixtures/dataRead.bas create mode 100644 src/test/basic/fixtures/dataRead.out create mode 100644 src/test/basic/fixtures/defTypes.bas create mode 100644 src/test/basic/fixtures/defTypes.out create mode 100644 src/test/basic/fixtures/divByZero.bas create mode 100644 src/test/basic/fixtures/divByZero.exit create mode 100644 src/test/basic/fixtures/divByZero.out create mode 100644 src/test/basic/fixtures/erlLine.bas create mode 100644 src/test/basic/fixtures/erlLine.out create mode 100644 src/test/basic/fixtures/fileBinary.bas create mode 100644 src/test/basic/fixtures/fileBinary.out create mode 100644 src/test/basic/fixtures/fileInputMulti.bas create mode 100644 src/test/basic/fixtures/fileInputMulti.out create mode 100644 src/test/basic/fixtures/fileRandom.bas create mode 100644 src/test/basic/fixtures/fileRandom.out create mode 100644 src/test/basic/fixtures/fileRandomUdt.bas create mode 100644 src/test/basic/fixtures/fileRandomUdt.out create mode 100644 src/test/basic/fixtures/fsDirOps.bas create mode 100644 src/test/basic/fixtures/fsDirOps.out create mode 100644 src/test/basic/fixtures/fsErrors.bas create mode 100644 src/test/basic/fixtures/fsErrors.out create mode 100644 src/test/basic/fixtures/gosub.bas create mode 100644 src/test/basic/fixtures/gosub.out create mode 100644 src/test/basic/fixtures/hexOct.bas create mode 100644 src/test/basic/fixtures/hexOct.out create mode 100644 src/test/basic/fixtures/iniReadWrite.bas create mode 100644 src/test/basic/fixtures/iniReadWrite.out create mode 100644 src/test/basic/fixtures/inputStdin.bas create mode 100644 src/test/basic/fixtures/inputStdin.in create mode 100644 src/test/basic/fixtures/inputStdin.out create mode 100644 src/test/basic/fixtures/intOverflow.bas create mode 100644 src/test/basic/fixtures/intOverflow.exit create mode 100644 src/test/basic/fixtures/intOverflow.out create mode 100644 src/test/basic/fixtures/lineContinuation.bas create mode 100644 src/test/basic/fixtures/lineContinuation.out create mode 100644 src/test/basic/fixtures/loopsMisc.bas create mode 100644 src/test/basic/fixtures/loopsMisc.out create mode 100644 src/test/basic/fixtures/mathFuncs.bas create mode 100644 src/test/basic/fixtures/mathFuncs.out create mode 100644 src/test/basic/fixtures/noFormRuntime.bas create mode 100644 src/test/basic/fixtures/noFormRuntime.exit create mode 100644 src/test/basic/fixtures/noFormRuntime.out create mode 100644 src/test/basic/fixtures/onError.bas create mode 100644 src/test/basic/fixtures/onError.exit create mode 100644 src/test/basic/fixtures/onError.out create mode 100644 src/test/basic/fixtures/onErrorSub.bas create mode 100644 src/test/basic/fixtures/onErrorSub.out create mode 100644 src/test/basic/fixtures/optionBase.bas create mode 100644 src/test/basic/fixtures/optionBase.out create mode 100644 src/test/basic/fixtures/parserExt.bas create mode 100644 src/test/basic/fixtures/parserExt.out create mode 100644 src/test/basic/fixtures/printTab.bas create mode 100644 src/test/basic/fixtures/printTab.out create mode 100644 src/test/basic/fixtures/printUsing.bas create mode 100644 src/test/basic/fixtures/printUsing.out create mode 100644 src/test/basic/fixtures/printUsingLiterals.bas create mode 100644 src/test/basic/fixtures/printUsingLiterals.out create mode 100644 src/test/basic/fixtures/procs.bas create mode 100644 src/test/basic/fixtures/procs.out create mode 100644 src/test/basic/fixtures/resumeNoError.bas create mode 100644 src/test/basic/fixtures/resumeNoError.exit create mode 100644 src/test/basic/fixtures/resumeNoError.out create mode 100644 src/test/basic/fixtures/selectCase.bas create mode 100644 src/test/basic/fixtures/selectCase.out create mode 100644 src/test/basic/fixtures/staticVars.bas create mode 100644 src/test/basic/fixtures/staticVars.out create mode 100644 src/test/basic/fixtures/stepCap.args create mode 100644 src/test/basic/fixtures/stepCap.bas create mode 100644 src/test/basic/fixtures/stepCap.exit create mode 100644 src/test/basic/fixtures/stepCap.out create mode 100644 src/test/basic/fixtures/stringCompare.bas create mode 100644 src/test/basic/fixtures/stringCompare.out create mode 100644 src/test/basic/fixtures/stringFuncs.bas create mode 100644 src/test/basic/fixtures/stringFuncs.out create mode 100644 src/test/basic/fixtures/swapVars.bas create mode 100644 src/test/basic/fixtures/swapVars.out create mode 100644 src/test/basic/fixtures/typedStore.bas create mode 100644 src/test/basic/fixtures/typedStore.out create mode 100644 src/test/basic/fixtures/typedStoreBoolean.bas create mode 100644 src/test/basic/fixtures/typedStoreBoolean.out create mode 100644 src/test/basic/fixtures/udtNested.bas create mode 100644 src/test/basic/fixtures/udtNested.out create mode 100644 src/test/basic/fuzzCommon.c create mode 100644 src/test/basic/fuzzCommon.h create mode 100644 src/test/basic/fuzzCompile.c create mode 100644 src/test/basic/fuzzModule.c create mode 100755 src/test/basic/runBasic.sh create mode 100644 src/test/coverage/Makefile create mode 100644 src/test/coverage/__pycache__/covReport.cpython-312.pyc create mode 100755 src/test/coverage/covReport.py create mode 100755 src/test/coverage/runCoverage.sh create mode 100644 src/test/dialogs/Makefile create mode 100644 src/test/dialogs/dialogsTest.c create mode 100644 src/test/dialogs/golden/msgBoxInfo32.png create mode 100644 src/test/dvxtest/Makefile create mode 100644 src/test/dvxtest/README.md create mode 100755 src/test/dvxtest/checkStatics.sh create mode 100644 src/test/dvxtest/dvxtest.c create mode 100644 src/test/dvxtest/dvxtest.h create mode 100644 src/test/dvxtest/dvxtest.mk create mode 100644 src/test/dvxtest/golden/covTextAreaSel.png create mode 100644 src/test/dvxtest/golden/covTextInputSel.png create mode 100644 src/test/dvxtest/golden/selfButton.png create mode 100644 src/test/dvxtest/golden/selfWindow.png create mode 100644 src/test/dvxtest/selfTest.c create mode 100644 src/test/dvxtest/staticsAllowlist.txt create mode 100644 src/test/formrt/Makefile create mode 100644 src/test/formrt/dvx.log create mode 100644 src/test/formrt/fixtures/icon.bmp create mode 100644 src/test/formrt/frtHarness.c create mode 100644 src/test/formrt/frtHarness.h create mode 100644 src/test/formrt/frtStubs.c create mode 100644 src/test/formrt/frtStubs.h create mode 100644 src/test/formrt/testFormRt.c create mode 100644 src/test/host/Makefile create mode 100644 src/test/host/smoke.c create mode 100644 src/test/ide/Makefile create mode 100644 src/test/ide/fixtures/fixture.dbp create mode 100644 src/test/ide/fixtures/form1.frm create mode 100644 src/test/ide/fixtures/main.bas create mode 100644 src/test/ide/ideStubs.c create mode 100755 src/test/ide/runCli.sh create mode 100644 src/test/ide/testIde.c create mode 100644 src/test/ide/testIdeDebug.c create mode 100644 src/test/ide/testIdeDesign.c create mode 100644 src/test/ide/testStub.c create mode 100644 src/test/lint/Makefile create mode 100644 src/test/lint/__pycache__/styleLint.cpython-312.pyc create mode 100644 src/test/lint/analyzerBaseline.txt create mode 100644 src/test/lint/asciiBaseline.txt create mode 100755 src/test/lint/asciiCheck.sh create mode 100644 src/test/lint/baseline.txt create mode 100644 src/test/lint/cppcheckBaseline.txt create mode 100755 src/test/lint/staticGate.sh create mode 100755 src/test/lint/styleLint.py create mode 100644 src/test/samples/golden/basdemo-debug.app create mode 100644 src/test/samples/golden/basdemo-release.app create mode 100644 src/test/samples/golden/helpedit-debug.app create mode 100644 src/test/samples/golden/helpedit-release.app create mode 100644 src/test/samples/golden/iconed-debug.app create mode 100644 src/test/samples/golden/iconed-release.app create mode 100644 src/test/samples/golden/imgview-debug.app create mode 100644 src/test/samples/golden/imgview-release.app create mode 100644 src/test/samples/golden/inc-comm-debug.app create mode 100644 src/test/samples/golden/inc-comm-release.app create mode 100644 src/test/samples/golden/inc-commdlg-debug.app create mode 100644 src/test/samples/golden/inc-commdlg-release.app create mode 100644 src/test/samples/golden/inc-help-debug.app create mode 100644 src/test/samples/golden/inc-help-release.app create mode 100644 src/test/samples/golden/inc-resource-debug.app create mode 100644 src/test/samples/golden/inc-resource-release.app create mode 100644 src/test/samples/golden/inc-sql-debug.app create mode 100644 src/test/samples/golden/inc-sql-release.app create mode 100644 src/test/samples/golden/notepad-debug.app create mode 100644 src/test/samples/golden/notepad-release.app create mode 100644 src/test/samples/golden/resedit-debug.app create mode 100644 src/test/samples/golden/resedit-release.app create mode 100644 src/test/samples/golden/widshow-debug.app create mode 100644 src/test/samples/golden/widshow-release.app create mode 100755 src/test/samples/runSamples.sh create mode 100644 src/test/serial/Makefile create mode 100644 src/test/serial/loopShim.c create mode 100644 src/test/serial/loopShim.h create mode 100644 src/test/serial/testProxy.c create mode 100644 src/test/serial/testSerial.c create mode 100644 src/test/shell/Makefile create mode 100644 src/test/shell/appsTest.c create mode 100644 src/test/shell/fixtures/fixBigStack.c create mode 100644 src/test/shell/fixtures/fixCrash.c create mode 100644 src/test/shell/fixtures/fixDecline.c create mode 100644 src/test/shell/fixtures/fixDesktop.c create mode 100644 src/test/shell/fixtures/fixEmpty.c create mode 100644 src/test/shell/fixtures/fixLoop.c create mode 100644 src/test/shell/fixtures/fixNoMain.c create mode 100644 src/test/shell/shellMainTest.c create mode 100644 src/test/shell/shellTest.c create mode 100644 src/test/shell/tasksTest.c create mode 100644 src/test/sql/Makefile create mode 100644 src/test/sql/sqlMain.c create mode 100644 src/test/tools/bmpDrawTest.c create mode 100644 src/test/tools/fixtures/bmp/splash.bmp create mode 100644 src/test/tools/fixtures/bmp/wrongSize.bmp create mode 100644 src/test/tools/fixtures/genFixtures.py create mode 100644 src/test/tools/fixtures/help/anim.gif create mode 100644 src/test/tools/fixtures/help/badDirective.dhs create mode 100644 src/test/tools/fixtures/help/brokenLink.dhs create mode 100644 src/test/tools/fixtures/help/extra.dhs create mode 100644 src/test/tools/fixtures/help/imageNoName.dhs create mode 100644 src/test/tools/fixtures/help/imgKinds.dhs create mode 100644 src/test/tools/fixtures/help/inc.dhs create mode 100644 src/test/tools/fixtures/help/includeNoFile.dhs create mode 100644 src/test/tools/fixtures/help/indexNoKey.dhs create mode 100644 src/test/tools/fixtures/help/inputs.txt create mode 100644 src/test/tools/fixtures/help/itemOutside.dhs create mode 100644 src/test/tools/fixtures/help/listEnd.dhs create mode 100644 src/test/tools/fixtures/help/main.dhs create mode 100644 src/test/tools/fixtures/help/missingImage.dhs create mode 100644 src/test/tools/fixtures/help/noteEnd.dhs create mode 100644 src/test/tools/fixtures/help/outsideTopic.dhs create mode 100644 src/test/tools/fixtures/help/photo.jpg create mode 100644 src/test/tools/fixtures/help/plain.xyz create mode 100644 src/test/tools/fixtures/help/sectionReset.dhs create mode 100644 src/test/tools/fixtures/help/selfInc.dhs create mode 100644 src/test/tools/fixtures/help/tiny.png create mode 100644 src/test/tools/fixtures/help/topicNoId.dhs create mode 100644 src/test/tools/fixtures/res/blob.bin create mode 100644 src/test/tools/fixtures/res/icon32.bmp create mode 100644 src/test/tools/fixtures/res/manifest.res create mode 100644 src/test/tools/fixtures/res/module.bin create mode 100644 src/test/tools/fixtures/res/resBadDirOff.bin create mode 100644 src/test/tools/fixtures/res/resDataShort.bin create mode 100644 src/test/tools/fixtures/res/resHugeCount.bin create mode 100644 src/test/tools/fixtures/res/resHugeSize.bin create mode 100644 src/test/tools/fixtures/res/resShortDir.bin create mode 100644 src/test/tools/golden/icons/mkicon_basic.bmp create mode 100644 src/test/tools/golden/icons/mkicon_clock.bmp create mode 100644 src/test/tools/golden/icons/mkicon_cpanel.bmp create mode 100644 src/test/tools/golden/icons/mkicon_dvxdemo.bmp create mode 100644 src/test/tools/golden/icons/mkicon_help.bmp create mode 100644 src/test/tools/golden/icons/mkicon_iconed.bmp create mode 100644 src/test/tools/golden/icons/mkicon_imgview.bmp create mode 100644 src/test/tools/golden/icons/mkicon_noicon.bmp create mode 100644 src/test/tools/golden/icons/mkicon_notepad.bmp create mode 100644 src/test/tools/golden/icons/mkicon_resedit.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_code.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_debug.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_design.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_open.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_run.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_runtocur.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_save.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_stepinto.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_stepout.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_stepover.bmp create mode 100644 src/test/tools/golden/icons/mktbicon_stop.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_button.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_canvas.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_checkbox.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_combobox.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_datactrl.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_dbgrid.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_dropdown.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_frame.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_hbox.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_image.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_imgbtn.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_label.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_listbox.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_listview.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_progress.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_radio.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_scrollpane.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_separator.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_slider.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_spacer.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_spinner.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_splitter.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_statusbar.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_tabctrl.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_terminal.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_textbox.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_timer.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_toolbar.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_treeview.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_vbox.bmp create mode 100644 src/test/tools/golden/icons/mkwgticon_wrapbox.bmp create mode 100644 src/test/tools/golden/main.hlp create mode 100644 src/test/tools/golden/main.html create mode 100644 src/test/tools/golden/resList1.txt create mode 100644 src/test/tools/golden/resList2.txt create mode 100644 src/test/tools/golden/splash.raw create mode 100644 src/test/tools/hlpcLibTest.c create mode 100644 src/test/tools/resFailTest.c create mode 100755 src/test/tools/runTools.sh create mode 100644 src/test/unit/Makefile create mode 100644 src/test/unit/testClock.c create mode 100644 src/test/unit/testComp.c create mode 100644 src/test/unit/testDraw.c create mode 100644 src/test/unit/testLayout.c create mode 100644 src/test/unit/testMem.c create mode 100644 src/test/unit/testPlatform.c create mode 100644 src/test/unit/testPrefs.c create mode 100644 src/test/unit/testResource.c create mode 100644 src/test/unit/testUtil.c create mode 100644 src/test/unit/testVideo.c create mode 100644 src/test/unit/testWm.c create mode 100644 src/test/unit/unitDisplay.c create mode 100644 src/test/unit/unitMain.c create mode 100644 src/test/unit/unitTest.h create mode 100644 src/test/widgets/Makefile rename src/{apps/kpunch/dvxbasic/test_lex.c => test/widgets/covMain.c} (69%) create mode 100644 src/test/widgets/testCovListView.c create mode 100644 src/test/widgets/testCovMisc.c create mode 100644 src/test/widgets/testCovPopups.c create mode 100644 src/test/widgets/testCovTerm.c create mode 100644 src/test/widgets/testCovText.c create mode 100644 src/test/widgets/testCovTreeView.c create mode 100644 src/test/widgets/testData.c create mode 100644 src/test/widgets/testDestroy.c create mode 100644 src/test/widgets/testFocus.c create mode 100644 src/test/widgets/testHover.c create mode 100644 src/test/widgets/testInput.c create mode 100644 src/test/widgets/testLists.c create mode 100644 src/test/widgets/testMisc.c create mode 100644 src/test/widgets/testProps.c create mode 100644 src/test/widgets/testRange.c create mode 100644 src/test/widgets/widgetsMain.c create mode 100644 src/test/widgets/wtCommon.c create mode 100644 src/test/widgets/wtCommon.h create mode 100644 src/test/wm/Makefile create mode 100644 src/test/wm/wmSuite.c create mode 100644 src/test/wm/wmSuite2.c diff --git a/.gitignore b/.gitignore index 91323dd..c315c3c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ lib/ .claude/ capture/ just-stuff/ +dvx.log diff --git a/Makefile b/Makefile index 4aac88e..d219d08 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ # src/loader/ -> bin/DVX.EXE # src/tools/ -> bin/host/ -.PHONY: all clean libdvx libtasks loader texthelp listhelp widgets dvxshell taskmgr serial sql apps tools deploy-helpsrc compile-help deploy-sdk +.PHONY: all clean libdvx libtasks loader texthelp listhelp widgets dvxshell taskmgr serial sql apps tools deploy-helpsrc compile-help deploy-sdk test test-quick test-asan test-host test-samples coverage all: libdvx libtasks loader texthelp listhelp tools widgets dvxshell taskmgr serial sql apps deploy-helpsrc compile-help deploy-sdk @@ -75,6 +75,48 @@ apps: libdvx libtasks dvxshell tools deploy-helpsrc: $(MAKE) -C src/tools deploy-helpsrc +# Host-side tests. Every suite is native gcc; the GUI suites build +# obj/host/libdvx.a, the widget .so files and libdvxtest.a themselves +# (sanitizers on by default, SAN=1). +# make test -- everything: BASIC harnesses + fuzz, sample goldens, +# proxy build, all host suites, tools goldens, lint gate +# make test-quick -- same minus the fuzzers, static analyzer and tools +# make test-asan -- BASIC harnesses rebuilt under ASan/UBSan plus the +# host suites (which are already sanitized) +DVXBASIC_DIR = src/apps/kpunch/dvxbasic +HOST_SUITES = src/test/dvxtest src/test/sql src/test/unit src/test/wm src/test/widgets src/test/dialogs src/test/formrt src/test/ide src/test/shell src/test/serial + +test: apps + $(MAKE) -C $(DVXBASIC_DIR) tests + $(MAKE) -C src/tools/proxy + $(MAKE) test-samples + $(MAKE) test-host + src/test/tools/runTools.sh + $(MAKE) -C src/test/lint + +test-quick: apps + $(MAKE) -C $(DVXBASIC_DIR) tests FUZZ_N=0 + $(MAKE) test-samples + $(MAKE) test-host + $(MAKE) -C src/test/lint style ascii + +test-asan: apps + $(MAKE) -C $(DVXBASIC_DIR) tests TESTSAN=1 + $(MAKE) test-host + +test-host: + @for d in $(HOST_SUITES); do $(MAKE) -C $$d || exit 1; done + +test-samples: + src/test/samples/runSamples.sh + +# Line coverage of the host suites: rebuilds everything with gcc +# --coverage (no sanitizers) under obj/hostcov, runs the BASIC +# harnesses, sample goldens and every host suite, then writes the +# report to obj/coverage/html and prints a per-directory summary. +coverage: tools + $(MAKE) -C src/test/coverage + HLPC = bin/host/dvxhlpc SYSTEM_DHS = src/libs/kpunch/libdvx/sysdoc.dhs \ diff --git a/docs/dvx_basic_reference.html b/docs/dvx_basic_reference.html index a9b5bc1..03271d9 100644 --- a/docs/dvx_basic_reference.html +++ b/docs/dvx_basic_reference.html @@ -273,6 +273,7 @@ img { max-width: 100%; }
  • EOF
  • EQV
  • ERASE
  • +
  • ERL
  • ERR
  • ERROR
  • Event Handlers
  • @@ -1225,11 +1226,15 @@ name$ = "Hello" ' String

    Type Promotion

    When mixing types in expressions, values are automatically promoted to a common type: Integer -> Long -> Single -> Double. Strings are not automatically converted to numbers (use VAL and STR$).

    Assignment Conversion

    -

    Storing a value into a variable declared as Integer, Long or Single (by suffix, by AS clause, by DEFINT/DEFLNG/DEFSNG, or as a typed parameter, TYPE field or array element) converts the value to that type. Integer and Long round to the nearest whole number, with halves rounding to the nearest even number (2.5 becomes 2, 3.5 becomes 4), and raise error 6 (Overflow) when the result does not fit. Single keeps about 7 significant digits. A variable with no declared type keeps whatever type the assigned value has.

    +

    Storing a value into a variable declared as Integer, Long, Single or Boolean (by suffix, by AS clause, by DEFINT/DEFLNG/DEFSNG, or as a typed parameter, TYPE field or array element) converts the value to that type. Integer and Long round to the nearest whole number, with halves rounding to the nearest even number (2.5 becomes 2, 3.5 becomes 4), and raise error 6 (Overflow) when the result does not fit. Single keeps about 7 significant digits, so PRINT shows at most 7 digits of it. Boolean stores True for any non-zero number or non-empty string and False otherwise. A variable declared with DIM but without a type is Single (or the DEFtype for its first letter). A variable that is never declared keeps whatever type the assigned value has.

    Dim n As Integer
     n = 3.7            ' n is 4
     n = 40000          ' Error 6: Overflow
    -x = 3.7            ' x has no declared type and keeps 3.7
    +Dim b As Boolean +b = 3 ' b is True +Dim s +s = 1 / 3 ' s is Single: prints 0.3333333 +x = 1 / 3 ' x was never declared and keeps the Double: prints 0.333333333333333

    Boolean Values

    Boolean values use -1 for True and 0 for False. Any non-zero numeric value is treated as True in a conditional context. The keywords True and False are reserved and may be used anywhere a Boolean value is expected.

    See also: Conversion Functions

    @@ -1319,9 +1324,11 @@ REM This is a comment DIM variable(upperBound) AS type DIM variable(lower TO upper) AS type DIM variable(dim1, dim2, ...) AS type +DIM variable() AS type DIM variable AS UdtName DIM variable AS STRING * n DIM SHARED variable AS type +

    An array declared with bounds is a fixed array. DIM variable() with empty parentheses declares a dynamic array that has no storage until a REDIM gives it bounds; using it before then raises error 13. An array that is first created by REDIM, and an array parameter, are dynamic too (see ERASE for the difference).

    Multiple variables can be declared at module level so their values persist between procedure calls. Arrays can have up to 8 dimensions. A variable declared with type suffix does not need the AS clause:

    Dim name$, count%, total&       ' suffixes infer the types

    Examples:

    @@ -1335,7 +1342,7 @@ Dim fixedStr As String * 20
    Note: DIM SHARED at module level makes a variable accessible from every procedure without passing it as a parameter. Inside a SUB or FUNCTION, DIM declares a local variable that is recreated on each call (use STATIC to retain its value between calls). A variable that is first used inside a SUB or FUNCTION without a DIM is also local to that procedure; to share it with module-level code or other procedures, declare it with DIM SHARED at module level.

    Fixed-length strings (STRING * n) are padded with spaces and truncated when assigned so their length is always exactly n.

    REDIM

    -

    Reallocates a dynamic array, optionally preserving existing data.

    +

    Reallocates an array, optionally preserving existing data. The array may have been declared with DIM name() or with bounds, or not declared at all (REDIM then declares a dynamic array).

    REDIM array(newBounds) AS type
     REDIM PRESERVE array(newBounds) AS type
    ReDim items(newSize) As String
    @@ -1354,18 +1361,19 @@ Const HEADER As String = "=== Report ==="

    Defines a user-defined type (record/structure).

    TYPE TypeName
         fieldName AS type
    +    fieldName AS STRING * n
         ...
     END TYPE
    Type PersonType
         firstName As String
    -    lastName  As String
    +    lastName  As String * 20
         age       As Integer
     End Type
     
     Dim p As PersonType
     p.firstName = "Scott"
     p.age = 30
    -

    UDT fields can themselves be UDTs (nested types).

    +

    UDT fields can themselves be UDTs (nested types), and field access chains through array elements too: shapes(i).origin.x = 5. A new instance starts with numeric fields at 0, Boolean fields False, variable strings empty and STRING * n fields padded to n spaces. TYPE variables can be written to and read from RANDOM and BINARY files with PUT and GET (see GET / PUT).

    DECLARE

    Forward-declares a SUB or FUNCTION. This is rarely required because the compiler supports forward references within a module, but it is still accepted for compatibility:

    DECLARE SUB name ([BYVAL] [OPTIONAL] param AS type, ...)
    @@ -1413,9 +1421,11 @@ udt.field = expression
     LET variable = expression

    The LET keyword is optional and supported for compatibility.

    SWAP

    -

    Exchanges the values of two variables. The variables must be the same type.

    -
    SWAP variable1, variable2
    -
    Swap a, b
    +

    Exchanges the values of two variables or array elements. The two operands should be the same type.

    +
    SWAP variable1, variable2
    +SWAP array(index), variable
    +
    Swap a, b
    +Swap names(i), names(j)

    SET

    Assigns an object reference (form or control) to a variable. Required when creating a form or control with CreateForm / CreateControl so that the returned reference is stored in the variable:

    SET variable = objectExpression
    @@ -1424,7 +1434,7 @@ Set frm = CreateForm("MyForm", 320, 240) frm.Caption = "Built in code"

    For ordinary numeric or string assignment, SET is not used.

    ERASE

    -

    Frees the memory of a dynamic array and resets it to undimensioned state. Fixed-size arrays (declared with constant bounds) reset their elements but keep their shape.

    +

    Frees the memory of a dynamic array (declared with DIM name(), created by REDIM, or an array parameter) and resets it to the undimensioned state; using it again before a REDIM raises error 13. A fixed array (declared with bounds) keeps its bounds and has every element reset to zero, an empty string, False or a fresh TYPE instance.

    ERASE arrayName
    @@ -1528,16 +1538,26 @@ Wend

    Procedures

    SUB...END SUB

    Defines a subroutine (no return value).

    -
    SUB name ([BYVAL] [OPTIONAL] param AS type, ...)
    +
    SUB name ([BYVAL | BYREF] [OPTIONAL] param AS type, ...)
    +SUB name (arrayParam() AS type, ...)
         statements
     END SUB
    Sub Greet(ByVal name As String)
         Print "Hello, " & name
     End Sub
    -

    Parameters are passed by reference by default. Use ByVal for value semantics; there is no separate ByRef keyword (omitting ByVal is the by-reference form). Writes to a by-reference parameter update the caller's variable, including individual array elements: `bump a(3)` passed to a by-reference parameter will modify `a(3)` in place. Use EXIT SUB to return early. A SUB is called either with or without parentheses; when used as a statement, parentheses are optional:

    +

    Parameters are passed by reference by default; BYREF may be written out to say so, and BYVAL selects value semantics. Writes to a by-reference parameter update the caller's variable, including individual array elements: `bump a(3)` passed to a by-reference parameter will modify `a(3)` in place. A parameter written as name() receives a whole array by reference; the caller passes `arr()` (or just `arr`), and the SUB may index, REDIM or ERASE it. Use EXIT SUB to return early. A SUB is called either with or without parentheses; when used as a statement, parentheses are optional:

    Greet "World"
     Greet("World")
     Call Greet("World")
    +
    Sub Fill(v() As Integer, ByRef count As Integer)
    +    ReDim v(9) As Integer
    +    v(9) = 7
    +    count = count + 1
    +End Sub
    +
    +Dim a(3) As Integer
    +Dim n As Integer
    +Fill a(), n

    Optional Parameters

    Mark a parameter OPTIONAL to allow callers to omit it. An optional parameter must be positioned after all required parameters and receives an empty/zero default when not supplied.

    Sub Announce(ByVal msg As String, Optional ByVal loud As Integer)
    @@ -1617,16 +1637,17 @@ PRINT USING format$; expression [; expression] ...

    Special functions inside PRINT:

    • SPC(n) -- print n spaces
    • -
    • TAB(n) -- advance to column n (first column is 1)
    • +
    • TAB(n) -- advance to column n (first column is 1); when the cursor is already past column n, a new line is started first
    Print "Name:"; Tab(20); name$
     Print "X="; x, "Y="; y        ' comma = next 14-column zone
     Print #1, "Written to file"

    PRINT USING

    -

    PRINT USING formats each following expression according to a format string and prints the result. If more expressions are supplied than the format string consumes, the format string is reused from the start for each.

    +

    PRINT USING formats each following expression according to a format string and prints the result. The format string is a mix of fields and literal text: each expression consumes the next field, the literal text before it is printed as it is, and the literal text after the last field is printed at the end. If more expressions are supplied than there are fields, the format string is reused from the start.

      Format character   Meaning
       ----------------   -------
    -  #                  Digit (replaced with a digit or space to pad on the left)
    +  #                  Digit position; unused positions on the left become spaces
    +  0                  Digit position that is always printed (zero padded); the first 0 sets the minimum number of digits
       .                  Decimal-point position
       ,                  Insert thousands separator
       +                  At start or end: always show a sign character
    @@ -1636,13 +1657,19 @@ Print #1, "Written to file"
    ^^^^ Anywhere: format the value in scientific notation ! First character of a string only & Entire string (variable length) - \ ... \ Fixed-length string field (width = 2 + number of spaces between backslashes)
    -
    Print Using "###.##"; 3.14159             ' "  3.14"
    -Print Using "$$#,##0.00"; 1234567.89      ' "$1,234,567.89"
    +  \ ... \            Fixed-length string field (width = 2 + number of spaces between backslashes)
    +  _                  The next character is printed literally
    +

    A numeric field is as wide as its format characters; the value is right-aligned in it and a negative sign (or a leading +) floats directly in front of the first digit. A value that does not fit is printed in full with a leading %. A string expression must meet a string field and a numeric expression a numeric field, otherwise error 13 (Type mismatch) is raised.

    +
    Print Using "###.##"; -3.14159            ' " -3.14"
    +Print Using "+###"; 42                    ' " +42"
    +Print Using "$$#,##0.00"; 1234.5          ' " $1,234.50"
     Print Using "**#,##0.00"; 42.5            ' "*****42.50"
    +Print Using "###"; 12345                  ' "%12345"
     Print Using "####.####^^^^"; 0.000123     ' scientific notation
     Print Using "\  \"; "Hello"               ' fixed-width 4-char: "Hell"
    -Print Using "& likes &"; name$; food$
    +Print Using "& likes &"; name$; food$ ' "Scott likes pizza" +Print Using "Total: ###.## units"; 7.5 ' "Total: 7.50 units" +Print Using "## and ##"; 1; 2; 3 ' " 1 and 2 3 and "

    INPUT

    Reads a line of text from the user or from a file channel.

    INPUT variable
    @@ -1669,13 +1696,13 @@ ON ERROR GOTO 0         ' Disable error handler
     RESUME                  ' Retry the statement that caused the error
     RESUME NEXT             ' Continue at the next statement after the error
     ERROR n                 ' Raise a runtime error with error number n
    -

    The ERR keyword returns the current error number in expressions (it is 0 when no error is active). RESUME or RESUME NEXT executed while no error is active raises error 20 (RESUME without error).

    +

    The ERR keyword returns the current error number in expressions and ERL the source line the error was raised on (both are 0 when no error is active, and both reset after RESUME). RESUME or RESUME NEXT executed while no error is active raises error 20 (RESUME without error).

    On Error GoTo ErrorHandler
     Open "missing.txt" For Input As #1
     Exit Sub
     
     ErrorHandler:
    -    Print "Error number:"; Err
    +    Print "Error number:"; Err; "on line"; Erl
         Resume Next

    Common Error Numbers

      Number   Meaning
    @@ -1692,13 +1719,14 @@ ErrorHandler:
       26       FOR loop nesting too deep
       51       Internal error (bad opcode)
       52       Bad file number or file not open
    -  53       File not found
    +  53       File not found (OPEN for INPUT, KILL, FILELEN, ...)
       54       Bad file mode
       58       File already exists or rename failed
    -  59       Bad record length (OPEN ... LEN must be 1 to 32767)
    +  59       Bad record length (OPEN ... LEN must be 1 to 32767; GET/PUT data longer than LEN)
       67       Too many files open
       75       Path/file access error
    -  76       Path not found
    + 76 Path not found + 1000 No form runtime (a form or control statement ran without the form runtime, e.g. under a headless runner)

    SHELL

    Executes an operating-system command.

    SHELL "command"
    @@ -1756,8 +1784,21 @@ Close #1

    Read and write records in RANDOM or BINARY mode files. When recordNum is omitted the transfer starts at the current file position.

    GET #channel, [recordNum], variable
     PUT #channel, [recordNum], variable
    -

    In RANDOM mode recordNum is a 1-based record number and each record is LEN bytes (see OPEN). Numbers are stored in their binary form (Integer 2 bytes, Long 4, Single 4, Double 8); a String is stored as a 2-byte length followed by its characters.

    +

    In RANDOM mode recordNum is a 1-based record number and each record is LEN bytes (see OPEN). Numbers are stored in their binary form (Integer and Boolean 2 bytes, Long 4, Single 4, Double 8); a String is stored as a 2-byte length followed by its characters. PUT always writes a whole record, padding the unused part with zero bytes, and GET always advances to the next record; data longer than LEN raises error 59.

    In BINARY mode recordNum is a 1-based byte position. Numbers are stored as in RANDOM mode. PUT writes the characters of a String with no length prefix, and GET reads as many bytes as the String variable currently holds, so set its length first (for example with SPACE$ or by declaring it as STRING * n).

    +

    The variable may be a TYPE variable. Its fields are packed in declaration order with the sizes above; a STRING * n field takes exactly n bytes (padded with spaces), a variable-length String field is stored with its 2-byte length prefix in both modes, and nested TYPE fields are packed inline. GET fills the fields of the existing variable in place.

    +

    The variable may also be an array element, arr(i), of any type including a TYPE: PUT writes that element and GET reads into it. The index expression of a GET target is evaluated twice (once to fetch the element, once to store it back), so keep it free of side effects.

    +
    Type RecT
    +    id    As Integer
    +    score As Double
    +    nm    As String * 6
    +End Type
    +Dim r As RecT
    +Open "recs.dat" For Random As #1 Len = 32
    +r.id = 1
    +Put #1, 1, r            ' 16 bytes of fields, padded to a 32-byte record
    +Get #1, 1, r
    +Close #1
    Dim buf As String
     Open "raw.bin" For Binary As #1
     buf = Space$(16)
    @@ -1822,7 +1863,7 @@ WHILE first$ <> ""
         first$ = DIR$        ' next match, no argument
     WEND

    FILELEN

    -

    Returns the length of a file in bytes.

    +

    Returns the length of a file in bytes. A file that does not exist raises error 53 (File not found).

    bytes = FILELEN(filename$)
    @@ -1855,10 +1896,12 @@ WEND

    FORMAT$ formats a numeric value using a BASIC-style format string. The format characters are the same as the ones used by PRINT USING.

    s$ = FORMAT$(value, fmt$)
    Print Format$(3.14159, "###.##")      ' "  3.14"
    +Print Format$(-7, "+###")             ' "  -7"
     Print Format$(42, "00000")            ' "00042"
     Print Format$(1234.5, "#,##0.00")     ' "1,234.50"
    +Print Format$(5, "#,##0.00")          ' "    5.00"
     Print Format$(0.5, "PERCENT")         ' "50%"
    -

    The accepted format characters are # (digit or pad space), 0 (digit or pad zero), . (decimal point), , (thousands separator), + and - (sign placement), $$ (floating dollar sign), ** (asterisk fill), and the literal word PERCENT (multiplies by 100 and appends %). See PRINT USING for details on each.

    +

    The whole format string is one numeric field. The accepted format characters are # (digit or pad space), 0 (digit that is always printed; zero pads up to the first 0), . (decimal point), , (thousands separator), + and - (sign placement), $$ (floating dollar sign), ** (asterisk fill), ^^^^ (scientific notation) and the literal word PERCENT (multiplies by 100 and appends %). Unlike PRINT USING, a value wider than the field is printed in full without a % marker. See PRINT USING for details on each character.

    MID$ Assignment

    MID$ can also be used on the left side of an assignment to replace a portion of a string without changing its length:

    Mid$(s$, start [, length]) = replacement$
    @@ -1917,10 +1960,10 @@ Me.BackColor = RGB(0, 0, 128) ' dark blue background EOF(channel) Boolean True if the file pointer is at end of file FREEFILE Integer Next available file channel number (1..16) INPUT$(n, #channel) String Reads exactly n characters from the file - LOC(channel) Long Current read/write position in the file + LOC(channel) Long BINARY: position of the last byte read or written (0 at the start); RANDOM: number of the last record read or written; sequential: number of 128-byte blocks read or written LOF(channel) Long Length of the file in bytes - SEEK(channel) Long Current file position (function form) - FILELEN(path$) Long Length of the named file in bytes (no OPEN needed) + SEEK(channel) Long 1-based position of the next byte to be read or written + FILELEN(path$) Long Length of the named file in bytes (no OPEN needed; error 53 when missing) GETATTR(path$) Integer File attribute bits (see vbReadOnly, vbHidden, etc.) CURDIR$ String Current working directory DIR$(pattern$) String First filename matching pattern, or "" @@ -1944,8 +1987,9 @@ Me.BackColor = RGB(0, 0, 128) ' dark blue background

    DVX BASIC supports Visual Basic-style forms and controls for building graphical user interfaces. A form is normally designed visually in the IDE and saved as a .frm file, but forms and their controls can also be built entirely in code.

    Loading and Unloading Forms

    LOAD FormName
    -UNLOAD FormName
    -

    LOAD creates the form and its controls in memory. It fires Form_Load when the form is first loaded. UNLOAD destroys the form, firing Form_QueryUnload (which can cancel the close) and then Form_Unload. The form name here is the literal name of the form as it appears in its .frm file.

    +UNLOAD FormName +UNLOAD Me +

    LOAD creates the form and its controls in memory. It fires Form_Load when the form is first loaded. UNLOAD destroys the form, firing Form_QueryUnload (which can cancel the close) and then Form_Unload. The form name here is the literal name of the form as it appears in its .frm file; UNLOAD Me unloads the form whose event handler is running.

    Showing and Hiding Forms

    FormName.Show [mode]
     FormName.Hide
    @@ -1961,6 +2005,10 @@ value = ControlName.Property
    Text1.Text = "Hello"
     Label1.Caption = "Name: " & name$
     x = Text1.Left
    +

    A control on another form is reached through that form's name, or through a variable holding a form reference: FormName.ControlName.Property works in both assignments and expressions, and so does FormName.ControlName.Method. Me.ControlName.Property does the same for the current form.

    +
    Form2.Label1.Caption = "From Form1"
    +total = Form2.List1.ListCount
    +Form2.List1.Clear

    Method Calls

    ControlName.Method [args]
    List1.AddItem "New entry"
    diff --git a/src/apps/kpunch/Makefile b/src/apps/kpunch/Makefile
    index ff62c7f..32d4b72 100644
    --- a/src/apps/kpunch/Makefile
    +++ b/src/apps/kpunch/Makefile
    @@ -48,6 +48,11 @@ all: $(C_APPS) dvxbasic $(BASIC_APPS)
     dvxbasic:
     	$(MAKE) -C dvxbasic
     
    +# bascomp is produced by the dvxbasic sub-make; this rule gives the BASIC app
    +# rules a real target to depend on so parallel builds order correctly.
    +$(BASCOMP): | dvxbasic
    +	@:
    +
     cpanel:  $(BINDIR)/kpunch/cpanel/cpanel.app
     progman: $(BINDIR)/kpunch/progman/progman.app
     clock:   $(BINDIR)/kpunch/clock/clock.app
    @@ -125,6 +130,57 @@ widshow: $(BINDIR)/kpunch/widshow/widshow.app
     $(BINDIR)/kpunch/widshow/widshow.app: widshow/widshow.dbp widshow/widshow.frm widshow/ICON32.BMP $(BASCOMP) | $(BINDIR)/kpunch/widshow dvxbasic
     	$(BASCOMP) widshow/widshow.dbp -o $@ -release
     
    +# Native host build (test harness): each C app becomes a plain ELF shared
    +# object under obj/host/apps/.so with its resources appended by
    +# dvxres exactly as on DOS (the resource footer trails the ELF image, which
    +# the dynamic loader ignores).  SAN=1 adds ASan/UBSan.
    +HOSTCC        = gcc
    +HOSTOBJDIR    = ../../../obj/host/apps
    +HOSTCFLAGS    = -O1 -g -fPIC -Wall -Wextra -Werror -Wno-type-limits -Wno-sign-compare -Wno-format-truncation -D_GNU_SOURCE -I../../libs/kpunch/libdvx -I../../libs/kpunch/libdvx/platform -I../../libs/kpunch/libdvx/thirdparty -I../../widgets/kpunch -I../../libs/kpunch/libtasks -I../../libs/kpunch/dvxshell
    +HOSTLDFLAGS   = -shared
    +ifeq ($(SAN),1)
    +HOSTCFLAGS   += -fsanitize=address,undefined -fno-omit-frame-pointer
    +HOSTLDFLAGS  += -fsanitize=address,undefined
    +endif
    +# COV=1 (make coverage): gcov instrumentation under obj/hostcov.
    +ifeq ($(COV),1)
    +HOSTOBJDIR    = ../../../obj/hostcov/apps
    +HOSTCFLAGS   += --coverage
    +HOSTLDFLAGS  += --coverage
    +endif
    +HOST_SOS      = $(foreach n,$(C_APPS),$(HOSTOBJDIR)/$(n).so)
    +
    +.PHONY: host host-clean
    +
    +host: $(HOST_SOS)
    +
    +$(HOSTOBJDIR)/progman.so: progman/progman.c $(COMMON_H) ../../libs/kpunch/dvxshell/shellInf.h
    +	@mkdir -p $(HOSTOBJDIR)
    +	$(HOSTCC) $(HOSTCFLAGS) $(HOSTLDFLAGS) -o $@ $<
    +
    +$(HOSTOBJDIR)/clock.so: clock/clock.c clock/clock.res clock/icon32.bmp $(COMMON_H)
    +	@mkdir -p $(HOSTOBJDIR)
    +	$(HOSTCC) $(HOSTCFLAGS) $(HOSTLDFLAGS) -o $@ $<
    +	cd clock && ../$(DVXRES) build ../$@ clock.res
    +
    +$(HOSTOBJDIR)/cpanel.so: cpanel/cpanel.c cpanel/cpanel.res cpanel/icon32.bmp $(COMMON_H)
    +	@mkdir -p $(HOSTOBJDIR)
    +	$(HOSTCC) $(HOSTCFLAGS) $(HOSTLDFLAGS) -o $@ $<
    +	cd cpanel && ../$(DVXRES) build ../$@ cpanel.res
    +
    +$(HOSTOBJDIR)/dvxdemo.so: dvxdemo/dvxdemo.c dvxdemo/dvxdemo.res dvxdemo/icon32.bmp $(COMMON_H) $(WIDGET_H)
    +	@mkdir -p $(HOSTOBJDIR)
    +	$(HOSTCC) $(HOSTCFLAGS) $(HOSTLDFLAGS) -o $@ $<
    +	cd dvxdemo && ../$(DVXRES) build ../$@ dvxdemo.res
    +
    +$(HOSTOBJDIR)/dvxhelp.so: dvxhelp/dvxhelp.c dvxhelp/hlpformat.h dvxhelp/dvxhelp.res dvxhelp/icon32.bmp $(COMMON_H)
    +	@mkdir -p $(HOSTOBJDIR)
    +	$(HOSTCC) $(HOSTCFLAGS) $(HOSTLDFLAGS) -o $@ $<
    +	cd dvxhelp && ../$(DVXRES) build ../$@ dvxhelp.res
    +
    +host-clean:
    +	rm -rf $(HOSTOBJDIR)
    +
     $(OBJDIR):
     	mkdir -p $(OBJDIR)
     
    diff --git a/src/apps/kpunch/cpanel/cpanel.c b/src/apps/kpunch/cpanel/cpanel.c
    index f2baf5f..5d4469e 100644
    --- a/src/apps/kpunch/cpanel/cpanel.c
    +++ b/src/apps/kpunch/cpanel/cpanel.c
    @@ -219,6 +219,24 @@ AppDescriptorT appDescriptor = {
     };
     
     
    +static void applyMouseConfig(void) {
    +    int32_t dir = (wgtDropdownGetSelected(sWheelDrop) == 1) ? -1 : 1;
    +    int32_t dbl = wgtSliderGetValue(sDblClickSldr);
    +
    +    const char *accelName = mapAccelValue(wgtDropdownGetSelected(sAccelDrop));
    +    int32_t     accelVal  = mapAccelName(accelName);
    +
    +    // Slider is inverted vs the mickey ratio (see CP_SPEED_INVERT):
    +    //   slider 2  -> 32 mickeys/8px (slowest)
    +    //   slider 26 -> 8  mickeys/8px (default)
    +    //   slider 32 -> 2  mickeys/8px (fastest)
    +    int32_t speedVal  = CP_SPEED_INVERT - wgtSliderGetValue(sSpeedSldr);
    +    int32_t wheelStep = wgtSliderGetValue(sWheelStepSldr);
    +
    +    dvxSetMouseConfig(sAc, dir, dbl, accelVal, speedVal, wheelStep);
    +}
    +
    +
     // Cleanup hook the shell calls before dlclose: these module-static stb_ds
     // arrays (and any still-open prefs handle) would otherwise leak into the
     // long-lived shell heap on every open/close, since the DXE unload doesn't
    @@ -244,24 +262,6 @@ void appShutdown(void) {
     }
     
     
    -static void applyMouseConfig(void) {
    -    int32_t dir = (wgtDropdownGetSelected(sWheelDrop) == 1) ? -1 : 1;
    -    int32_t dbl = wgtSliderGetValue(sDblClickSldr);
    -
    -    const char *accelName = mapAccelValue(wgtDropdownGetSelected(sAccelDrop));
    -    int32_t     accelVal  = mapAccelName(accelName);
    -
    -    // Slider is inverted vs the mickey ratio (see CP_SPEED_INVERT):
    -    //   slider 2  -> 32 mickeys/8px (slowest)
    -    //   slider 26 -> 8  mickeys/8px (default)
    -    //   slider 32 -> 2  mickeys/8px (fastest)
    -    int32_t speedVal  = CP_SPEED_INVERT - wgtSliderGetValue(sSpeedSldr);
    -    int32_t wheelStep = wgtSliderGetValue(sWheelStepSldr);
    -
    -    dvxSetMouseConfig(sAc, dir, dbl, accelVal, speedVal, wheelStep);
    -}
    -
    -
     static void buildColorsTab(WidgetT *page) {
         // Color list on the left, sliders on the right
         WidgetT *hbox = wgtHBox(page);
    @@ -518,12 +518,24 @@ static void buildVideoTab(WidgetT *page) {
             const char *depthName;
     
             switch (sVideoModes[i].bpp) {
    -            case 8:  depthName = "256 colors";          break;
    -            case 15: depthName = "32 thousand colors";   break;
    -            case 16: depthName = "65 thousand colors";   break;
    -            case 24: depthName = "16 million colors";    break;
    -            case 32: depthName = "16 million colors+";   break;
    -            default: depthName = "";                     break;
    +            case 8:
    +                depthName = "256 colors";
    +                break;
    +            case 15:
    +                depthName = "32 thousand colors";
    +                break;
    +            case 16:
    +                depthName = "65 thousand colors";
    +                break;
    +            case 24:
    +                depthName = "16 million colors";
    +                break;
    +            case 32:
    +                depthName = "16 million colors+";
    +                break;
    +            default:
    +                depthName = "";
    +                break;
             }
     
             snprintf(sLabelBufs[i], sizeof(sLabelBufs[i]), "%ldx%ld %s", (long)sVideoModes[i].w, (long)sVideoModes[i].h, depthName);
    diff --git a/src/apps/kpunch/dvxbasic/Makefile b/src/apps/kpunch/dvxbasic/Makefile
    index bbcdcb7..629df12 100644
    --- a/src/apps/kpunch/dvxbasic/Makefile
    +++ b/src/apps/kpunch/dvxbasic/Makefile
    @@ -45,7 +45,7 @@ HOSTDIR = ../../../../bin/host
     DVXRES  = $(HOSTDIR)/dvxres
     
     # Runtime library objects (VM + values + form runtime + serialization)
    -RT_OBJS = $(OBJDIR)/vm.o $(OBJDIR)/values.o $(OBJDIR)/formrt.o $(OBJDIR)/frmParser.o $(OBJDIR)/serialize.o
    +RT_OBJS = $(OBJDIR)/vm.o $(OBJDIR)/values.o $(OBJDIR)/formrt.o $(OBJDIR)/frmParser.o $(OBJDIR)/basNativeDos.o $(OBJDIR)/serialize.o
     RT_TARGETDIR = $(LIBSDIR)/kpunch/basrt
     RT_TARGET    = $(RT_TARGETDIR)/basrt.lib
     
    @@ -61,34 +61,51 @@ APP_TARGET = $(APPDIR)/dvxbasic.app
     STUB_OBJS = $(OBJDIR)/basstub.o
     STUB_TARGET = $(OBJDIR)/basstub.app
     
    -# Native test programs (host gcc, not cross-compiled)
    +# Native test programs (host gcc, not cross-compiled).
    +# TESTSAN=1 rebuilds the harnesses under ASan/UBSan (make tests TESTSAN=1).
     HOSTCC     = gcc
     HOSTCFLAGS = -O2 $(WARNFLAGS) -Wno-stringop-truncation -D_GNU_SOURCE $(DVX_INCLUDES)
    +ifeq ($(TESTSAN),1)
    +HOSTCFLAGS += -g -fno-omit-frame-pointer -fsanitize=address,undefined
    +endif
    +# COV=1 (make coverage): every host build (harnesses, bascomp, basrun,
    +# basrt.a) gets gcov instrumentation and lands under obj/hostcov so it
    +# never mixes with the sanitized or release host binaries.  dvxres is
    +# still the release tool from bin/host.  Must precede the rules below
    +# because target names expand when they are read.
    +ifeq ($(COV),1)
    +HOSTDIR         = ../../../../obj/hostcov/basic
    +DVXRES          = ../../../../bin/host/dvxres
    +HOSTOBJDIR      = ../../../../obj/hostcov
    +HOSTCFLAGS     += -g --coverage
    +HOSTRT_COVFLAGS = --coverage
    +endif
     
     # Every header a host harness can pull in; listed as a prerequisite so a
     # header edit rebuilds the harness instead of leaving a stale binary.
     HEADERS    = $(wildcard *.h compiler/*.h runtime/*.h formrt/*.h)
     
     TEST_COMPILER = $(HOSTDIR)/test_compiler
    -TEST_VM       = $(HOSTDIR)/test_vm
    -TEST_LEX      = $(HOSTDIR)/test_lex
    -TEST_QUICK    = $(HOSTDIR)/test_quick
     TEST_COMPACT  = $(HOSTDIR)/test_compact
     TEST_SUITE    = $(HOSTDIR)/test_suite
     
     STB_DS_IMPL        = ../../../libs/kpunch/libdvx/thirdparty/stb_ds_impl.c
     PLATFORM_UTIL     = ../../../libs/kpunch/libdvx/platform/dvxPlatformUtil.c
     TEST_COMPILER_SRCS = test_compiler.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c runtime/vm.c runtime/values.c runtime/serialize.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
    -TEST_VM_SRCS       = test_vm.c runtime/vm.c runtime/values.c runtime/serialize.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
    -TEST_LEX_SRCS      = test_lex.c compiler/lexer.c
    -TEST_QUICK_SRCS    = test_quick.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c runtime/vm.c runtime/values.c runtime/serialize.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
     TEST_COMPACT_SRCS  = test_compact.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c compiler/strip.c compiler/compact.c runtime/vm.c runtime/values.c runtime/serialize.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
    -TEST_SUITE_SRCS    = test_suite.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c compiler/strip.c compiler/obfuscate.c compiler/compact.c runtime/vm.c runtime/values.c runtime/serialize.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
    +TEST_SUITE_SRCS    = test_suite.c basBuild.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c compiler/strip.c compiler/obfuscate.c compiler/compact.c runtime/vm.c runtime/values.c runtime/serialize.c ../../../libs/kpunch/libdvx/dvxPrefs.c ../../../libs/kpunch/libdvx/dvxResource.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
     
     # Command-line compiler (host tool)
     BASCOMP_SRCS   = stub/bascomp.c basBuild.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c compiler/strip.c compiler/obfuscate.c compiler/compact.c runtime/vm.c runtime/values.c runtime/serialize.c ../../../libs/kpunch/libdvx/dvxPrefs.c ../../../libs/kpunch/libdvx/dvxResource.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
     BASCOMP_TARGET = $(HOSTDIR)/bascomp
     
    +# Headless host runner (host tool; runs .app/.bas/.dbp with PRINT on stdout)
    +BASRUN_SRCS   = stub/basrun.c basBuild.c compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c compiler/strip.c compiler/obfuscate.c compiler/compact.c runtime/vm.c runtime/values.c runtime/serialize.c ../../../libs/kpunch/libdvx/dvxPrefs.c ../../../libs/kpunch/libdvx/dvxResource.c $(PLATFORM_UTIL) $(STB_DS_IMPL)
    +BASRUN_TARGET = $(HOSTDIR)/basrun
    +
    +# Host-side BASIC test directory (basrun fixtures, fuzzers, compaction diff)
    +BASIC_TESTDIR = ../../../test/basic
    +
     # DOS command-line compiler
     DOSCC       = $(DJGPP_PREFIX)/bin/i586-pc-msdosdjgpp-gcc
     DOSCFLAGS   = -O2 $(WARNFLAGS) -march=i486 -mtune=i586 $(DVX_INCLUDES)
    @@ -98,37 +115,37 @@ SYSTEMDIR   = ../../../../bin/system
     
     .PHONY: all clean tests
     
    -all: $(RT_TARGET) $(RT_TARGETDIR)/basrt.dep $(STUB_TARGET) $(APP_TARGET) $(BASCOMP_TARGET) $(SYSTEMDIR)/BASCOMP.EXE
    +all: $(RT_TARGET) $(RT_TARGETDIR)/basrt.dep $(STUB_TARGET) $(APP_TARGET) $(BASCOMP_TARGET) $(BASRUN_TARGET) $(SYSTEMDIR)/BASCOMP.EXE
     
     # Run every built harness and fail the build if any returns non-zero.
    -# test_vm/test_lex/test_quick are inspection harnesses with no pass/fail
    -# of their own, so they run output-suppressed purely for crash detection;
     # test_compiler, test_compact and test_suite each report a failure count
     # and return non-zero on failure.  make stops at the first one that fails.
    -tests: $(TEST_COMPILER) $(TEST_VM) $(TEST_LEX) $(TEST_QUICK) $(TEST_COMPACT) $(TEST_SUITE)
    -	$(TEST_VM) > /dev/null
    -	$(TEST_LEX) > /dev/null
    -	$(TEST_QUICK) > /dev/null
    +# The harness binaries depend on the sanitizer stamp so switching TESTSAN
    +# on or off forces a rebuild instead of running a stale binary.
    +TESTSAN_STAMP = $(HOSTDIR)/.testsan-$(if $(filter 1,$(TESTSAN)),on,off)
    +
    +# The src/test/basic suite (sample runs, fixtures, fuzzers, compaction
    +# differential) needs bascomp and basrun; it inherits TESTSAN.
    +tests: $(TEST_COMPILER) $(TEST_COMPACT) $(TEST_SUITE) $(BASCOMP_TARGET) $(BASRUN_TARGET)
     	$(TEST_COMPILER)
     	$(TEST_COMPACT)
     	$(TEST_SUITE)
    +	$(MAKE) -C $(BASIC_TESTDIR) test TESTSAN=$(TESTSAN)
     
    -$(TEST_COMPILER): $(TEST_COMPILER_SRCS) $(HEADERS) | $(HOSTDIR)
    +$(BASRUN_TARGET): $(BASRUN_SRCS) $(HEADERS) $(TESTSAN_STAMP) | $(HOSTDIR)
    +	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(BASRUN_SRCS) -lm
    +
    +$(TEST_COMPILER): $(TEST_COMPILER_SRCS) $(HEADERS) $(TESTSAN_STAMP) | $(HOSTDIR)
     	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(TEST_COMPILER_SRCS) -lm
     
    -$(TEST_SUITE): $(TEST_SUITE_SRCS) $(HEADERS) | $(HOSTDIR)
    -	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(TEST_SUITE_SRCS) -lm
    +$(TEST_SUITE): $(TEST_SUITE_SRCS) $(HEADERS) $(TESTSAN_STAMP) | $(HOSTDIR)
    +	$(HOSTCC) $(HOSTCFLAGS) -I../../../tools -DBAS_TEST_SRC_DIR='"$(abspath .)"' -o $@ $(TEST_SUITE_SRCS) -lm
     
    -$(TEST_VM): $(TEST_VM_SRCS) $(HEADERS) | $(HOSTDIR)
    -	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(TEST_VM_SRCS) -lm
    +$(TESTSAN_STAMP): | $(HOSTDIR)
    +	rm -f $(HOSTDIR)/.testsan-on $(HOSTDIR)/.testsan-off
    +	touch $@
     
    -$(TEST_LEX): $(TEST_LEX_SRCS) $(HEADERS) | $(HOSTDIR)
    -	$(HOSTCC) $(HOSTCFLAGS) -w -o $@ $(TEST_LEX_SRCS) -lm
    -
    -$(TEST_QUICK): $(TEST_QUICK_SRCS) $(HEADERS) | $(HOSTDIR)
    -	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(TEST_QUICK_SRCS) -lm
    -
    -$(TEST_COMPACT): $(TEST_COMPACT_SRCS) $(HEADERS) | $(HOSTDIR)
    +$(TEST_COMPACT): $(TEST_COMPACT_SRCS) $(HEADERS) $(TESTSAN_STAMP) | $(HOSTDIR)
     	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(TEST_COMPACT_SRCS) -lm
     
     # Host command-line compiler -- basstub.app is appended as a STUB
    @@ -211,4 +228,49 @@ $(APPDIR):
     
     clean:
     	rm -rf $(RT_OBJS) $(COMP_OBJS) $(IDE_OBJS) $(STUB_OBJS) $(OBJDIR)/*.d $(RT_TARGET) $(APP_TARGET) $(STUB_TARGET) $(BASCOMP_TARGET) $(SYSTEMDIR)/BASCOMP.EXE $(RT_TARGETDIR)/basrt.dep $(RT_TARGETDIR)
    -	rm -f $(TEST_COMPILER) $(TEST_VM) $(TEST_LEX) $(TEST_QUICK) $(TEST_COMPACT) $(TEST_SUITE)
    +	rm -f $(TEST_COMPILER) $(TEST_COMPACT) $(TEST_SUITE) $(BASRUN_TARGET) $(HOSTDIR)/.testsan-on $(HOSTDIR)/.testsan-off
    +	$(MAKE) -C $(BASIC_TESTDIR) clean
    +
    +
    +# ============================================================
    +# Host basrt archive (src/test/formrt suite) -- T5 block, keep last
    +# ============================================================
    +#
    +# Compiler + runtime + form runtime built with the native toolchain into
    +# obj/host/basrt.a, linked by the form-runtime test suite against the
    +# host libdvx.a and the widget .so files.  basNativeHost.c stands in for
    +# the i386 basNativeDos.c trampoline (Makefile object-list selection, no
    +# preprocessor conditionals).  SAN must match the rest of the host build.
    +SAN            ?= 1
    +HOSTOBJDIR     ?= ../../../../obj/host
    +HOSTRT_DIR      = $(HOSTOBJDIR)/basrt
    +HOSTRT_TARGET   = $(HOSTOBJDIR)/basrt.a
    +HOSTRT_SRCS     = compiler/lexer.c compiler/parser.c compiler/codegen.c compiler/symtab.c compiler/strip.c compiler/obfuscate.c compiler/compact.c runtime/vm.c runtime/values.c runtime/serialize.c formrt/formrt.c formrt/frmParser.c formrt/basNativeHost.c
    +HOSTRT_OBJS     = $(patsubst %.c,$(HOSTRT_DIR)/%.o,$(notdir $(HOSTRT_SRCS)))
    +HOSTRT_CFLAGS   = -O1 -g $(WARNFLAGS) -Wno-stringop-truncation -D_GNU_SOURCE $(DVX_INCLUDES) -I../../../widgets/kpunch -I../../../libs/kpunch/dvxshell -I../../../libs/kpunch/libtasks $(HOSTRT_COVFLAGS)
    +ifeq ($(SAN),1)
    +HOSTRT_CFLAGS  += -fsanitize=address,undefined -fno-omit-frame-pointer
    +endif
    +
    +.PHONY: host-basrt host-basrt-clean
    +
    +host-basrt: $(HOSTRT_TARGET)
    +
    +$(HOSTRT_TARGET): $(HOSTRT_OBJS)
    +	rm -f $@
    +	ar rcs $@ $(HOSTRT_OBJS)
    +
    +$(HOSTRT_DIR)/%.o: compiler/%.c $(HEADERS)
    +	@mkdir -p $(HOSTRT_DIR)
    +	$(HOSTCC) $(HOSTRT_CFLAGS) -c -o $@ $<
    +
    +$(HOSTRT_DIR)/%.o: runtime/%.c $(HEADERS)
    +	@mkdir -p $(HOSTRT_DIR)
    +	$(HOSTCC) $(HOSTRT_CFLAGS) -c -o $@ $<
    +
    +$(HOSTRT_DIR)/%.o: formrt/%.c $(HEADERS)
    +	@mkdir -p $(HOSTRT_DIR)
    +	$(HOSTCC) $(HOSTRT_CFLAGS) -c -o $@ $<
    +
    +host-basrt-clean:
    +	rm -rf $(HOSTRT_DIR) $(HOSTRT_TARGET)
    diff --git a/src/apps/kpunch/dvxbasic/basBuild.c b/src/apps/kpunch/dvxbasic/basBuild.c
    index 96e7563..053b928 100644
    --- a/src/apps/kpunch/dvxbasic/basBuild.c
    +++ b/src/apps/kpunch/dvxbasic/basBuild.c
    @@ -72,9 +72,18 @@
     // DJGPP as well as every host OS bascomp runs on; DVX_PATH_SEP is not.
     #define BAS_BUILD_SEP          "/"
     
    +// Growth unit of the project source buffer.
    +#define BAS_BUILD_SOURCE_CHUNK 8192
    +// Room for a BEGINFORM "name" / ENDFORM wrapper around a form's code.
    +#define BAS_BUILD_WRAP_SLACK   (BAS_MAX_IDENT + 32)
    +
     // Function prototypes (alphabetical)
    +static bool        appendSource(BasProjectSourceT *ps, const char *text, int32_t len);
     static int32_t     appendText(const char *path, const char *name, const char *value);
     const char        *basBuildApp(const char *outPath, const BasBuildSpecT *spec);
    +bool               basBuildAppendFile(BasProjectSourceT *ps, const char *text, int32_t len, const char *formName, int32_t *textLine, int32_t *textLines);
    +bool               basBuildConcatProject(PrefsHandleT *prefs, const char *projectDir, BasProjectSourceT *out);
    +void               basBuildFreeProject(BasProjectSourceT *out);
     static void        buildLog(const BasBuildSpecT *spec, const char *fmt, ...);
     static const char *copyHelpFile(const char *outPath, const BasBuildSpecT *spec);
     static int32_t     emitIcon(const char *outPath, const BasBuildSpecT *spec);
    @@ -83,6 +92,48 @@ static const char *prepareModule(const BasBuildSpecT *spec, uint8_t **frmData, i
     static const char *writeStub(const char *outPath, const BasBuildSpecT *spec);
     
     
    +// Appends text to the project source and makes sure it ends on a newline
    +// so the next file starts on a fresh line.
    +static bool appendSource(BasProjectSourceT *ps, const char *text, int32_t len) {
    +    int32_t need = ps->sourceLen + len + 2;
    +
    +    if (need > ps->sourceCap) {
    +        int32_t newCap = ps->sourceCap ? ps->sourceCap : BAS_BUILD_SOURCE_CHUNK;
    +
    +        while (newCap < need) {
    +            newCap *= 2;
    +        }
    +
    +        char *grown = (char *)realloc(ps->source, (size_t)newCap);
    +
    +        if (!grown) {
    +            snprintf(ps->error, sizeof(ps->error), "Out of memory.");
    +            return false;
    +        }
    +
    +        ps->source    = grown;
    +        ps->sourceCap = newCap;
    +    }
    +
    +    memcpy(ps->source + ps->sourceLen, text, (size_t)len);
    +    ps->sourceLen += len;
    +
    +    for (int32_t i = 0; i < len; i++) {
    +        if (text[i] == '\n') {
    +            ps->lineCount++;
    +        }
    +    }
    +
    +    if (ps->sourceLen > 0 && ps->source[ps->sourceLen - 1] != '\n') {
    +        ps->source[ps->sourceLen++] = '\n';
    +        ps->lineCount++;
    +    }
    +
    +    ps->source[ps->sourceLen] = '\0';
    +    return true;
    +}
    +
    +
     static int32_t appendText(const char *path, const char *name, const char *value) {
         if (!value || !value[0]) {
             return 0;
    @@ -161,6 +212,121 @@ const char *basBuildApp(const char *outPath, const BasBuildSpecT *spec) {
     }
     
     
    +bool basBuildAppendFile(BasProjectSourceT *ps, const char *text, int32_t len, const char *formName, int32_t *textLine, int32_t *textLines) {
    +    bool isForm = (formName && formName[0]);
    +
    +    if (isForm) {
    +        char    wrap[BAS_BUILD_WRAP_SLACK];
    +        int32_t n = snprintf(wrap, sizeof(wrap), "BEGINFORM \"%s\"\n", formName);
    +
    +        if (!appendSource(ps, wrap, n)) {
    +            return false;
    +        }
    +    }
    +
    +    int32_t startLine = ps->lineCount + 1;
    +
    +    if (!appendSource(ps, text, len)) {
    +        return false;
    +    }
    +
    +    if (textLine) {
    +        *textLine = startLine;
    +    }
    +
    +    if (textLines) {
    +        *textLines = ps->lineCount + 1 - startLine;
    +    }
    +
    +    if (isForm) {
    +        return appendSource(ps, "ENDFORM\n", (int32_t)strlen("ENDFORM\n"));
    +    }
    +
    +    return true;
    +}
    +
    +
    +bool basBuildConcatProject(PrefsHandleT *prefs, const char *projectDir, BasProjectSourceT *out) {
    +    static const char *const sections[2] = { BAS_INI_SECTION_MODULES, BAS_INI_SECTION_FORMS };
    +
    +    memset(out, 0, sizeof(*out));
    +    out->optionExplicit = prefsGetBool(prefs, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_OPTIONEXPLICIT, false);
    +
    +    // Pass 0: .bas modules, pass 1: .frm code sections.
    +    for (int32_t pass = 0; pass < 2; pass++) {
    +        bool isForm = (pass == 1);
    +
    +        for (int32_t i = 0; ; i++) {
    +            char key[BAS_BUILD_LOG_BUF];
    +            snprintf(key, sizeof(key), "File%d", (int)i);
    +            const char *val = prefsGetString(prefs, sections[pass], key, NULL);
    +
    +            if (!val) {
    +                break;
    +            }
    +
    +            char filePath[DVX_MAX_PATH];
    +            snprintf(filePath, sizeof(filePath), "%s%s%s", projectDir, BAS_BUILD_SEP, val);
    +
    +            int32_t srcLen = 0;
    +            char   *src    = platformReadFile(filePath, &srcLen);
    +
    +            if (!src) {
    +                snprintf(out->error, sizeof(out->error), "Cannot read %s", filePath);
    +                return false;
    +            }
    +
    +            bool ok;
    +
    +            if (isForm) {
    +                char formName[BAS_MAX_IDENT] = "";
    +                basExtractFormName(src, formName, BAS_MAX_IDENT);
    +
    +                // The BASIC code section follows the outer Begin Form block.
    +                const char *code = src + basFindFormEndPos(src, srcLen);
    +
    +                ok = basBuildAppendFile(out, code, (int32_t)strlen(code), formName, NULL, NULL);
    +
    +                char *frmPath = (char *)malloc(strlen(filePath) + 1);
    +
    +                if (frmPath) {
    +                    strcpy(frmPath, filePath);
    +                    arrput(out->frmPaths, frmPath);
    +                }
    +            } else {
    +                ok = basBuildAppendFile(out, src, (int32_t)strlen(src), NULL, NULL, NULL);
    +            }
    +
    +            free(src);
    +
    +            if (!ok) {
    +                return false;
    +            }
    +
    +            out->fileCount++;
    +        }
    +    }
    +
    +    if (out->fileCount == 0) {
    +        snprintf(out->error, sizeof(out->error), "Project has no source files.");
    +        return false;
    +    }
    +
    +    return true;
    +}
    +
    +
    +void basBuildFreeProject(BasProjectSourceT *out) {
    +    for (int32_t i = 0; i < (int32_t)arrlen(out->frmPaths); i++) {
    +        free(out->frmPaths[i]);
    +    }
    +
    +    arrfree(out->frmPaths);
    +    free(out->source);
    +    memset(out, 0, sizeof(*out));
    +}
    +
    +
     static void buildLog(const BasBuildSpecT *spec, const char *fmt, ...) {
         if (!spec->log) {
             return;
    diff --git a/src/apps/kpunch/dvxbasic/basBuild.h b/src/apps/kpunch/dvxbasic/basBuild.h
    index 5d80444..34db3ab 100644
    --- a/src/apps/kpunch/dvxbasic/basBuild.h
    +++ b/src/apps/kpunch/dvxbasic/basBuild.h
    @@ -37,6 +37,8 @@
     #define BAS_BUILD_H
     
     #include "runtime/vm.h"
    +#include "dvxPrefs.h"
    +#include "dvxTypes.h"
     
     #include 
     #include 
    @@ -83,6 +85,40 @@ typedef struct {
     // error message.  On failure outPath may be left partially written.
     const char *basBuildApp(const char *outPath, const BasBuildSpecT *spec);
     
    +// Longest error text basBuildConcatProject reports (a path plus a reason).
    +#define BAS_BUILD_ERROR_LEN (DVX_MAX_PATH + 64)
    +
    +// A project's BASIC source as the compiler consumes it: every [Modules]
    +// file in order, then the code section of every [Forms] file wrapped in
    +// BEGINFORM "name" / ENDFORM, each file ending on a newline.  frmPaths
    +// lists the .frm files in the same order for the resource step.
    +typedef struct {
    +    char    *source;          // NUL-terminated, malloc'd
    +    int32_t  sourceLen;
    +    int32_t  sourceCap;
    +    int32_t  lineCount;       // newlines in source (next text starts on lineCount + 1)
    +    char   **frmPaths;        // stb_ds array of malloc'd paths
    +    int32_t  fileCount;       // modules + forms
    +    bool     optionExplicit;  // [Settings] OptionExplicit
    +    char     error[BAS_BUILD_ERROR_LEN];
    +} BasProjectSourceT;
    +
    +// Appends one file's text to *ps (a zeroed struct is a valid empty
    +// project) and makes sure it ends on a newline.  A non-empty formName
    +// wraps the text in BEGINFORM "name" / ENDFORM.  textLine and textLines
    +// (either may be NULL) receive the 1-based line the text starts on and
    +// the number of lines it occupies, wrapper lines excluded -- what an IDE
    +// source map needs.  Returns false with ps->error set on failure.
    +bool basBuildAppendFile(BasProjectSourceT *ps, const char *text, int32_t len, const char *formName, int32_t *textLine, int32_t *textLines);
    +
    +// Reads the source files named by an open .dbp (paths relative to
    +// projectDir) into *out.  Returns false with out->error set on failure;
    +// call basBuildFreeProject either way.
    +bool basBuildConcatProject(PrefsHandleT *prefs, const char *projectDir, BasProjectSourceT *out);
    +
    +// Releases everything basBuildConcatProject allocated.
    +void basBuildFreeProject(BasProjectSourceT *out);
    +
     #ifdef __cplusplus
     }
     #endif
    diff --git a/src/apps/kpunch/dvxbasic/compiler/codegen.c b/src/apps/kpunch/dvxbasic/compiler/codegen.c
    index 9306d27..9cd46de 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/codegen.c
    +++ b/src/apps/kpunch/dvxbasic/compiler/codegen.c
    @@ -46,7 +46,6 @@ void basEmit16(BasCodeGenT *cg, int16_t v);
     void basEmit8(BasCodeGenT *cg, uint8_t b);
     static void basEmitBytes(BasCodeGenT *cg, const void *data, int32_t len);
     void basEmitDouble(BasCodeGenT *cg, double v);
    -void basEmitFloat(BasCodeGenT *cg, float v);
     void basEmitU16(BasCodeGenT *cg, uint16_t v);
     const BasProcEntryT *basModuleFindProc(const BasModuleT *mod, const char *name);
     void basPatch16(BasCodeGenT *cg, int32_t pos, int16_t val);
    @@ -393,10 +392,6 @@ void basEmitDouble(BasCodeGenT *cg, double v) {
     }
     
     
    -void basEmitFloat(BasCodeGenT *cg, float v) {
    -    basEmitBytes(cg, &v, (int32_t)sizeof(v));
    -}
    -
     
     void basEmitU16(BasCodeGenT *cg, uint16_t v) {
         basEmitBytes(cg, &v, (int32_t)sizeof(v));
    diff --git a/src/apps/kpunch/dvxbasic/compiler/codegen.h b/src/apps/kpunch/dvxbasic/compiler/codegen.h
    index 8da95e7..eca3379 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/codegen.h
    +++ b/src/apps/kpunch/dvxbasic/compiler/codegen.h
    @@ -67,8 +67,8 @@ typedef struct {
     // API
     // ============================================================
     
    -void basCodeGenInit(BasCodeGenT *cg);
     void basCodeGenFree(BasCodeGenT *cg);
    +void basCodeGenInit(BasCodeGenT *cg);
     
     // Emit single byte
     void basEmit8(BasCodeGenT *cg, uint8_t b);
    @@ -80,7 +80,6 @@ void basEmit16(BasCodeGenT *cg, int16_t v);
     void basEmitU16(BasCodeGenT *cg, uint16_t v);
     
     // Emit 32-bit float
    -void basEmitFloat(BasCodeGenT *cg, float v);
     
     // Emit 64-bit double
     void basEmitDouble(BasCodeGenT *cg, double v);
    diff --git a/src/apps/kpunch/dvxbasic/compiler/compact.c b/src/apps/kpunch/dvxbasic/compiler/compact.c
    index 1b14775..ff44b3d 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/compact.c
    +++ b/src/apps/kpunch/dvxbasic/compiler/compact.c
    @@ -100,6 +100,13 @@ int32_t basCompactBytecode(BasModuleT *mod) {
                     keepStmt = true;
                 }
     
    +            // ERL reports the line of the current error, which only the
    +            // OP_LINE stream provides: a module that uses it keeps every
    +            // line marker.
    +            if (op == OP_ERL) {
    +                return 0;
    +            }
    +
                 pc += 1 + operand;
             }
     
    @@ -326,22 +333,14 @@ static int32_t *buildRemap(const uint8_t *code, int32_t codeLen, bool keepStmt,
         int32_t oldPc  = 0;
         int32_t newPc  = 0;
     
    +    // basCompactBytecode's pre-scan already validated every opcode and
    +    // instruction boundary in this stream, so the walk needs no checks
    +    // of its own -- one source of truth for what a well-formed stream is.
         while (oldPc < codeLen) {
    -        uint8_t op      = code[oldPc];
    -        int32_t operand = basOpcodeOperandSize(op);
    -
    -        if (operand < 0) {
    -            free(remap);
    -            return NULL;
    -        }
    -
    +        uint8_t op       = code[oldPc];
    +        int32_t operand  = basOpcodeOperandSize(op);
             int32_t instSize = 1 + operand;
     
    -        if (oldPc + instSize > codeLen) {
    -            free(remap);
    -            return NULL;
    -        }
    -
             if (op == OP_LINE) {
                 // Dropped entirely, or shrunk to OP_STMT: the opcode byte maps
                 // to the boundary marker (if kept) and the operand bytes to the
    @@ -366,11 +365,6 @@ static int32_t *buildRemap(const uint8_t *code, int32_t codeLen, bool keepStmt,
             oldPc += instSize;
         }
     
    -    if (oldPc != codeLen) {
    -        free(remap);
    -        return NULL;
    -    }
    -
         remap[codeLen] = newPc;
         *outNewLen = newPc;
         return remap;
    diff --git a/src/apps/kpunch/dvxbasic/compiler/lexer.c b/src/apps/kpunch/dvxbasic/compiler/lexer.c
    index 6290a03..4b269fe 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/lexer.c
    +++ b/src/apps/kpunch/dvxbasic/compiler/lexer.c
    @@ -56,6 +56,7 @@ static const KeywordEntryT sKeywords[] = {
         KW("BASE",            TOK_BASE),
         KW("BINARY",          TOK_BINARY),
         KW("BOOLEAN",         TOK_BOOLEAN),
    +    KW("BYREF",           TOK_BYREF),
         KW("BYVAL",           TOK_BYVAL),
         KW("CALL",            TOK_CALL),
         KW("CASE",            TOK_CASE),
    @@ -87,6 +88,7 @@ static const KeywordEntryT sKeywords[] = {
         KW("EOF",             TOK_EOF_KW),
         KW("EQV",             TOK_EQV),
         KW("ERASE",           TOK_ERASE),
    +    KW("ERL",             TOK_ERL),
         KW("ERR",             TOK_ERR),
         KW("ERROR",           TOK_ERROR_KW),
         KW("EXIT",            TOK_EXIT),
    diff --git a/src/apps/kpunch/dvxbasic/compiler/lexer.h b/src/apps/kpunch/dvxbasic/compiler/lexer.h
    index f066795..abffcf5 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/lexer.h
    +++ b/src/apps/kpunch/dvxbasic/compiler/lexer.h
    @@ -76,6 +76,7 @@ typedef enum {
         TOK_AS,
         TOK_BASE,
         TOK_BOOLEAN,
    +    TOK_BYREF,
         TOK_BYVAL,
         TOK_CALL,
         TOK_CASE,
    @@ -101,6 +102,7 @@ typedef enum {
         TOK_EOF_KW,            // EOF (keyword, not end-of-file)
         TOK_EQV,
         TOK_ERASE,
    +    TOK_ERL,
         TOK_ERR,
         TOK_ERROR_KW,
         TOK_EXPLICIT,
    diff --git a/src/apps/kpunch/dvxbasic/compiler/opcodes.h b/src/apps/kpunch/dvxbasic/compiler/opcodes.h
    index d0939ed..4c2d61c 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/opcodes.h
    +++ b/src/apps/kpunch/dvxbasic/compiler/opcodes.h
    @@ -246,7 +246,7 @@ typedef enum {
     
     #define OP_DIM_ARRAY       0x90  // [uint8 dims] [uint8 type] bounds on stack
     #define OP_REDIM           0x91  // [uint8 dims] [uint8 preserve] [uint8 type] bounds on stack (UDT: typeId, fieldCount after bounds)
    -#define OP_ERASE           0x92  // array ref on stack
    +#define OP_ERASE           0x92  // [uint8 mode] pop array ref, push replacement (BAS_ERASE_ZERO keeps the shape, BAS_ERASE_FREE undimensions)
     #define OP_LBOUND          0x93  // [uint8 dim] array ref on stack
     #define OP_UBOUND          0x94  // [uint8 dim] array ref on stack
     #define OP_ON_ERROR        0x95  // [int16 handler] set error handler (0 = disable)
    @@ -254,6 +254,7 @@ typedef enum {
     #define OP_RESUME_NEXT     0x97  // resume at next statement
     #define OP_RAISE_ERR       0x98  // error number on stack
     #define OP_ERR_NUM         0x99  // push current error number
    +#define OP_ERL             0x9A  // push the source line of the current error (0 when none)
     #define OP_STORE_FORM_VAR  0x9B  // [uint16 idx] pop, store to currentFormVars[idx]
     #define OP_PUSH_FORM_ADDR  0x9C  // [uint16 idx] push ¤tFormVars[idx] (ByRef)
     #define OP_CREATE_CTRL_EX  0x9D  // pop parentRef, pop name, pop type, pop formRef, push ctrlRef
    @@ -324,7 +325,7 @@ typedef enum {
     #define OP_FILE_PUT        0xBF  // pop channel + recno + value, write record
     #define OP_FILE_SEEK       0xC0  // pop channel + position, seek
     #define OP_FILE_LOF        0xC1  // pop channel, push file length
    -#define OP_FILE_LOC        0xC2  // pop channel, push current position
    +#define OP_FILE_LOC        0xC2  // pop channel, push LOC (last byte / record / block, by mode)
     #define OP_FILE_FREEFILE   0xC3  // push next free channel number
     #define OP_FILE_INPUT_N    0xC4  // pop channel + n, read n chars, push string
     
    @@ -339,7 +340,7 @@ typedef enum {
     // PRINT USING
     // ============================================================
     
    -#define OP_PRINT_USING     0xC7  // pop format + value, push formatted string
    +#define OP_PRINT_USING     0xC7  // [uint8 flags] pop format + value, push format + formatted string (BAS_USING_FIRST / BAS_USING_LAST)
     
     // ============================================================
     // SPC(n) and TAB(n) with stack-based argument
    @@ -396,6 +397,8 @@ typedef enum {
     #define OP_CREATE_FORM     0xF0  // pop height, pop width, pop nameStr, push formRef
     #define OP_SET_EVENT       0xF1  // pop handlerNameStr, pop eventNameStr, pop ctrlRef
     #define OP_REMOVE_CTRL     0xF2  // pop ctrlNameStr, pop formRef
    +#define OP_FILE_PUT_UDT    0xF6  // pop layoutStr, pop udt, pop recno, pop channel; write packed fields
    +#define OP_FILE_SEEK_GET   0xF7  // pop channel, push 1-based file position (SEEK function)
     
     // ============================================================
     // Halt
    @@ -404,6 +407,27 @@ typedef enum {
     #define OP_END             0xFE  // explicit END statement -- terminates program
     #define OP_HALT            0xFF  // implicit end of module
     
    +// OP_PRINT_USING flag bits: FIRST rewinds the format scan to the start
    +// of the format string, LAST appends the literal text that follows the
    +// consumed field.
    +#define BAS_USING_FIRST    0x01
    +#define BAS_USING_LAST     0x02
    +
    +// OP_ERASE modes.
    +#define BAS_ERASE_ZERO     0     // fixed array: reset every element, keep the bounds
    +#define BAS_ERASE_FREE     1     // dynamic array: release the storage (undimensioned until REDIM)
    +
    +// TYPE layout descriptors (built by the compiler as a string constant,
    +// walked by the VM): one BAS_TYPE_* byte per field in declaration order.
    +// A STRING entry is followed by a little-endian uint16 fixed length (0 =
    +// variable length); a BAS_TYPE_UDT entry by the nested type's uint16
    +// typeId and uint16 fieldCount and then its own entries.  DIM/REDIM use
    +// the layout to build fully initialised instances, GET/PUT to pack and
    +// unpack the fields (variable strings carry an int16 length prefix).
    +#define BAS_LAYOUT_MAX_ENTRY_LEN    5
    +#define BAS_LAYOUT_MAX_LEN          1024
    +#define BAS_LAYOUT_MAX_DEPTH        16    // nesting cap for TYPE fields inside a layout
    +
     // ============================================================
     // Operand-size table and bytecode pattern helpers
     // ============================================================
    @@ -437,6 +461,25 @@ typedef enum {
     #define BAS_GOSUB_PATTERN_LEN   8   // PUSH_INT32(1+4) + JMP(1+2)
     #define BAS_GOSUB_JMP_OFFSET    5   // byte offset of the OP_JMP inside the pattern
     
    +static inline bool basIsGosubPush(const uint8_t *code, int32_t codeLen, int32_t pos);
    +static inline int32_t basOpcodeOperandSize(uint8_t op);
    +static inline int32_t basReadI32LE(const uint8_t *p);
    +
    +
    +// True when code[pos] starts a GOSUB call sequence (see BAS_GOSUB_PATTERN_LEN).
    +static inline bool basIsGosubPush(const uint8_t *code, int32_t codeLen, int32_t pos) {
    +    if (pos + BAS_GOSUB_PATTERN_LEN > codeLen) {
    +        return false;
    +    }
    +
    +    if (code[pos] != OP_PUSH_INT32 || code[pos + BAS_GOSUB_JMP_OFFSET] != OP_JMP) {
    +        return false;
    +    }
    +
    +    return basReadI32LE(code + pos + 1) == pos + BAS_GOSUB_PATTERN_LEN;
    +}
    +
    +
     // Returns operand byte count for op, or BAS_OPERAND_UNKNOWN.
     static inline int32_t basOpcodeOperandSize(uint8_t op) {
         switch (op) {
    @@ -471,7 +514,6 @@ static inline int32_t basOpcodeOperandSize(uint8_t op) {
             case OP_MSGBOX:          case OP_INPUTBOX:     case OP_ME_REF:
             case OP_CREATE_CTRL:     case OP_FIND_CTRL:    case OP_FIND_CTRL_IDX:
             case OP_CREATE_CTRL_EX:
    -        case OP_ERASE:
             case OP_RESUME:          case OP_RESUME_NEXT:
             case OP_RAISE_ERR:       case OP_ERR_NUM:
             case OP_MATH_ABS:        case OP_MATH_INT:     case OP_MATH_FIX:
    @@ -490,7 +532,7 @@ static inline int32_t basOpcodeOperandSize(uint8_t op) {
             case OP_FILE_GET:        case OP_FILE_PUT:     case OP_FILE_SEEK:
             case OP_FILE_LOF:        case OP_FILE_LOC:     case OP_FILE_FREEFILE:
             case OP_FILE_INPUT_N:
    -        case OP_STR_MID_ASGN:    case OP_PRINT_USING:
    +        case OP_STR_MID_ASGN:
             case OP_PRINT_TAB_N:     case OP_PRINT_SPC_N:
             case OP_FORMAT:          case OP_SHELL:
             case OP_APP_PATH:        case OP_APP_CONFIG:   case OP_APP_DATA:
    @@ -501,6 +543,7 @@ static inline int32_t basOpcodeOperandSize(uint8_t op) {
             case OP_FS_DIR_NEXT:     case OP_FS_FILELEN:
             case OP_FS_GETATTR:      case OP_FS_SETATTR:
             case OP_CREATE_FORM:     case OP_SET_EVENT:    case OP_REMOVE_CTRL:
    +        case OP_FILE_PUT_UDT:    case OP_ERL:          case OP_FILE_SEEK_GET:
             case OP_END:             case OP_HALT:
                 return BAS_OPERAND_NONE;
     
    @@ -509,7 +552,8 @@ static inline int32_t basOpcodeOperandSize(uint8_t op) {
             case OP_FILE_OPEN:
             case OP_CALL_METHOD:     case OP_SHOW_FORM:
             case OP_LBOUND:          case OP_UBOUND:
    -        case OP_COMPARE_MODE:
    +        case OP_COMPARE_MODE:    case OP_PRINT_USING:
    +        case OP_ERASE:
                 return BAS_OPERAND_U8;
     
             case OP_PUSH_INT16:      case OP_PUSH_STR:
    @@ -556,17 +600,4 @@ static inline int32_t basReadI32LE(const uint8_t *p) {
     }
     
     
    -// True when code[pos] starts a GOSUB call sequence (see BAS_GOSUB_PATTERN_LEN).
    -static inline bool basIsGosubPush(const uint8_t *code, int32_t codeLen, int32_t pos) {
    -    if (pos + BAS_GOSUB_PATTERN_LEN > codeLen) {
    -        return false;
    -    }
    -
    -    if (code[pos] != OP_PUSH_INT32 || code[pos + BAS_GOSUB_JMP_OFFSET] != OP_JMP) {
    -        return false;
    -    }
    -
    -    return basReadI32LE(code + pos + 1) == pos + BAS_GOSUB_PATTERN_LEN;
    -}
    -
     #endif // DVXBASIC_OPCODES_H
    diff --git a/src/apps/kpunch/dvxbasic/compiler/parser.c b/src/apps/kpunch/dvxbasic/compiler/parser.c
    index 0b3f238..9a2c372 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/parser.c
    +++ b/src/apps/kpunch/dvxbasic/compiler/parser.c
    @@ -131,6 +131,9 @@ typedef struct {
     #define PARAM_PRESCAN     (1u << 1)  // accept AS  for a TYPE not parsed yet
     #define PARAM_FORCE_BYVAL (1u << 2)  // DEF FN: parameters are always by value
     
    +// Name prefix of compiler temporaries; '$' cannot start a BASIC identifier.
    +#define BAS_TEMP_PREFIX   "$tmp"
    +
     // State saved around a FOR / DO / WHILE loop body.
     typedef struct {
         ExitListT list;
    @@ -164,12 +167,23 @@ typedef struct {
     static void addPatchAddr(BasSymbolT *sym, int32_t addr);
     static void addPredefConst(BasParserT *p, const char *name, int32_t val);
     static void addPredefConsts(BasParserT *p);
    +// One SWAP operand: a scalar variable or an array element.  For an
    +// element the lexer position of its index list is kept so the store
    +// back can re-emit the indices after the temporaries are filled.
    +typedef struct {
    +    BasSymbolT *sym;
    +    BasLexerT   indexLex;
    +    int32_t     dims;
    +    bool        isElement;
    +} SwapOperandT;
    +
     static void advance(BasParserT *p);
     static void applyParamList(BasSymbolT *sym, const ParamListT *pl);
     bool basParse(BasParserT *p);
     BasModuleT *basParserBuildModule(BasParserT *p);
     void basParserFree(BasParserT *p);
     void basParserInit(BasParserT *p, const char *source, int32_t sourceLen);
    +static bool buildUdtLayout(BasParserT *p, int32_t typeId, uint8_t *buf, int32_t *len, int32_t depth);
     static bool check(BasParserT *p, BasTokenTypeE type);
     static bool checkCtrlArrayAccess(BasParserT *p);
     static void checkIdentLength(BasParserT *p);
    @@ -181,9 +195,10 @@ static void collectDebugGlobals(BasParserT *p);
     static void collectDebugLocals(BasParserT *p, int32_t procIndex);
     static void emitByRefArg(BasParserT *p);
     static void emitCallWithArgs(BasParserT *p, BasSymbolT *sym, int32_t argc);
    +static void emitFieldStoreConv(BasParserT *p, const BasFieldDefT *field);
     static void emitFunctionCall(BasParserT *p, BasSymbolT *sym);
    -static void emitInputConv(BasParserT *p, const BasSymbolT *sym);
     static void emitGotoWithSelectPops(BasParserT *p, const char *labelName);
    +static void emitInputConv(BasParserT *p, const BasSymbolT *sym);
     static int32_t emitJump(BasParserT *p, uint8_t opcode);
     static void emitJumpToLabel(BasParserT *p, uint8_t opcode, const char *labelName);
     static void emitLoad(BasParserT *p, BasSymbolT *sym);
    @@ -191,7 +206,9 @@ static void emitMethodCallStatement(BasParserT *p);
     static void emitSelectPops(BasParserT *p, int32_t count);
     static void emitStore(BasParserT *p, BasSymbolT *sym);
     static void emitStoreConv(BasParserT *p, uint8_t dataType);
    -static void emitUdtInit(BasParserT *p, int32_t udtTypeId);
    +static void emitSwapStore(BasParserT *p, const SwapOperandT *op, BasSymbolT *tmp);
    +static bool emitUdtLayout(BasParserT *p, int32_t typeId);
    +static bool emitUdtSpec(BasParserT *p, int32_t typeId);
     static BasSymbolT *ensureVariable(BasParserT *p, const char *name);
     static void error(BasParserT *p, const char *msg);
     static void errorExpected(BasParserT *p, const char *what);
    @@ -205,10 +222,12 @@ static void exprLeave(BasParserT *p);
     static const BuiltinFuncT *findBuiltin(const char *name);
     static BasSymbolT *findTypeDef(BasParserT *p, const char *name);
     static BasSymbolT *findTypeDefById(BasParserT *p, int32_t typeId);
    +static uint8_t implicitType(BasParserT *p, const char *name, bool *outTyped);
     static void loopBegin(BasParserT *p, bool isFor, LoopSaveT *save);
     static void loopEnd(BasParserT *p, bool isFor, LoopSaveT *save);
     static bool match(BasParserT *p, BasTokenTypeE type);
     static bool nameHasTypeSuffix(const char *name);
    +static BasSymbolT *newTempVar(BasParserT *p);
     static void parseAddExpr(BasParserT *p);
     static void parseAndExpr(BasParserT *p);
     static void parseAssignOrCall(BasParserT *p);
    @@ -234,15 +253,20 @@ static void parseEqvExpr(BasParserT *p);
     static void parseErase(BasParserT *p);
     static void parseExit(BasParserT *p);
     static void parseExpression(BasParserT *p);
    +static void parseFieldLoadChain(BasParserT *p, int32_t typeId);
    +static void parseFieldStoreChain(BasParserT *p, int32_t typeId);
     static void parseFileCopy(BasParserT *p);
    +static int32_t parseFixedStringLen(BasParserT *p);
     static void parseFor(BasParserT *p);
     static void parseFunction(BasParserT *p);
     static void parseGet(BasParserT *p);
    +static void parseGetArrayElement(BasParserT *p, BasSymbolT *sym);
     static void parseGosub(BasParserT *p);
     static void parseGoto(BasParserT *p);
     static void parseIdivExpr(BasParserT *p);
     static void parseIf(BasParserT *p);
     static void parseImpExpr(BasParserT *p);
    +static int32_t parseIndexList(BasParserT *p);
     static void parseInput(BasParserT *p);
     static void parseKill(BasParserT *p);
     static void parseLineInput(BasParserT *p);
    @@ -252,6 +276,7 @@ static void parseMulDivExpr(BasParserT *p);
     static void parseMulExpr(BasParserT *p);
     static void parseName(BasParserT *p);
     static void parseNotExpr(BasParserT *p);
    +static void parseObjectMemberExpr(BasParserT *p);
     static void parseOn(BasParserT *p);
     static void parseOnError(BasParserT *p);
     static void parseOpen(BasParserT *p);
    @@ -280,6 +305,7 @@ static void parseStatement(BasParserT *p);
     static void parseStatic(BasParserT *p);
     static void parseSub(BasParserT *p);
     static void parseSwap(BasParserT *p);
    +static bool parseSwapOperand(BasParserT *p, SwapOperandT *op);
     static void parseType(BasParserT *p);
     static void parseUnaryExpr(BasParserT *p);
     static void parseWhile(BasParserT *p);
    @@ -298,6 +324,7 @@ static uint8_t resolveTypeName(BasParserT *p);
     static void shiftBackpatchAddrs(BasParserT *p, int32_t from, int32_t delta);
     static void skipNewlines(BasParserT *p);
     static uint8_t suffixToType(const char *name);
    +static bool swapNeedsTemps(BasParserT *p);
     
     // Record a code position that must be patched once sym (a forward-
     // referenced SUB/FUNCTION/label) is defined.
    @@ -471,6 +498,57 @@ void basParserSetValidator(BasParserT *p, const BasCtrlValidatorT *v) {
     }
     
     
    +// Builds the layout of a TYPE (see BAS_LAYOUT_* in opcodes.h):
    +// one entry per field in declaration order, nested types inline.
    +static bool buildUdtLayout(BasParserT *p, int32_t typeId, uint8_t *buf, int32_t *len, int32_t depth) {
    +    BasSymbolT *typeSym = findTypeDefById(p, typeId);
    +
    +    if (typeSym == NULL) {
    +        error(p, "Unknown TYPE definition");
    +        return false;
    +    }
    +
    +    if (depth >= BAS_LAYOUT_MAX_DEPTH) {
    +        error(p, "TYPE nesting too deep for GET/PUT");
    +        return false;
    +    }
    +
    +    for (int32_t i = 0; i < typeSym->fieldCount; i++) {
    +        const BasFieldDefT *field = &typeSym->fields[i];
    +
    +        if (*len + BAS_LAYOUT_MAX_ENTRY_LEN > BAS_LAYOUT_MAX_LEN) {
    +            error(p, "TYPE has too many fields");
    +            return false;
    +        }
    +
    +        buf[(*len)++] = field->dataType;
    +
    +        if (field->dataType == BAS_TYPE_STRING) {
    +            buf[(*len)++] = (uint8_t)(field->fixedLen & 0xFF);
    +            buf[(*len)++] = (uint8_t)((field->fixedLen >> 8) & 0xFF);
    +        } else if (field->dataType == BAS_TYPE_UDT) {
    +            BasSymbolT *childType = findTypeDefById(p, field->udtTypeId);
    +
    +            if (childType == NULL) {
    +                error(p, "Unknown TYPE definition");
    +                return false;
    +            }
    +
    +            buf[(*len)++] = (uint8_t)(field->udtTypeId & 0xFF);
    +            buf[(*len)++] = (uint8_t)((field->udtTypeId >> 8) & 0xFF);
    +            buf[(*len)++] = (uint8_t)(childType->fieldCount & 0xFF);
    +            buf[(*len)++] = (uint8_t)((childType->fieldCount >> 8) & 0xFF);
    +
    +            if (!buildUdtLayout(p, field->udtTypeId, buf, len, depth + 1)) {
    +                return false;
    +            }
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
     static bool check(BasParserT *p, BasTokenTypeE type) {
         return p->lex.token.type == type;
     }
    @@ -584,6 +662,10 @@ static void collectDebugGlobals(BasParserT *p) {
         for (int32_t i = 0; i < p->sym.count; i++) {
             BasSymbolT *s = p->sym.symbols[i];
     
    +        if (s->name[0] == BAS_TEMP_PREFIX[0]) {
    +            continue;
    +        }
    +
             if (s->scope == SCOPE_GLOBAL && s->kind == SYM_VARIABLE) {
                 basCodeGenAddDebugVar(&p->cg, s->name, SCOPE_GLOBAL, s->dataType, s->index, -1, NULL);
             } else if (s->scope == SCOPE_FORM && s->kind == SYM_VARIABLE) {
    @@ -631,7 +713,7 @@ static void collectDebugLocals(BasParserT *p, int32_t procIndex) {
         for (int32_t i = 0; i < p->sym.count; i++) {
             BasSymbolT *s = p->sym.symbols[i];
     
    -        if (s->scope == SCOPE_LOCAL && s->kind == SYM_VARIABLE) {
    +        if (s->scope == SCOPE_LOCAL && s->kind == SYM_VARIABLE && s->name[0] != BAS_TEMP_PREFIX[0]) {
                 basCodeGenAddDebugVar(&p->cg, s->name, SCOPE_LOCAL, s->dataType, s->index, procIndex, NULL);
             }
         }
    @@ -676,20 +758,25 @@ static void emitByRefArg(BasParserT *p) {
         if (sym->isArray && check(p, TOK_LPAREN)) {
             advance(p); // consume (
     
    +        // "arr()" passes the whole array by reference, like a bare name.
    +        if (match(p, TOK_RPAREN)) {
    +            if (sym->scope == SCOPE_LOCAL) {
    +                basEmit8(&p->cg, OP_PUSH_LOCAL_ADDR);
    +            } else if (sym->scope == SCOPE_FORM) {
    +                basEmit8(&p->cg, OP_PUSH_FORM_ADDR);
    +            } else {
    +                basEmit8(&p->cg, OP_PUSH_GLOBAL_ADDR);
    +            }
    +
    +            basEmitU16(&p->cg, (uint16_t)sym->index);
    +            return;
    +        }
    +
             // Push the array reference
             emitLoad(p, sym);
     
             // Parse indices
    -        int32_t dims = 0;
    -        parseExpression(p);
    -        dims++;
    -
    -        while (match(p, TOK_COMMA)) {
    -            parseExpression(p);
    -            dims++;
    -        }
    -
    -        expect(p, TOK_RPAREN);
    +        int32_t dims = parseIndexList(p);
     
             // If the next token isn't an argument delimiter, this wasn't
             // a simple `arr(i)` -- rewind and fall back.  (rare; e.g.
    @@ -822,6 +909,17 @@ static void emitCallWithArgs(BasParserT *p, BasSymbolT *sym, int32_t argc) {
     }
     
     
    +// Conversion for a value about to be stored into a TYPE field: fixed
    +// strings are padded/truncated, numeric and BOOLEAN fields coerced.
    +static void emitFieldStoreConv(BasParserT *p, const BasFieldDefT *field) {
    +    if (field->fixedLen > 0) {
    +        basEmit8(&p->cg, OP_STR_FIXLEN);
    +        basEmitU16(&p->cg, (uint16_t)field->fixedLen);
    +    }
    +    emitStoreConv(p, field->dataType);
    +}
    +
    +
     // name(arg, ...) in either statement or expression context.
     static void emitFunctionCall(BasParserT *p, BasSymbolT *sym) {
         int32_t argc = parseCallArgs(p, sym, true);
    @@ -1070,52 +1168,71 @@ static void emitStoreConv(BasParserT *p, uint8_t dataType) {
             case BAS_TYPE_SINGLE:
                 basEmit8(&p->cg, OP_CONV_INT_FLT);
                 break;
    +        case BAS_TYPE_BOOLEAN:
    +            basEmit8(&p->cg, OP_CONV_BOOL);
    +            break;
             default:
                 break;
         }
     }
     
     
    -// emitUdtInit -- emit code to initialize nested UDT fields after a UDT
    -// has been created and is on top of the stack.  For each field that is
    -// itself a UDT, we DUP the parent, allocate the child UDT, and store it
    -// into the field.
    -
    -static void emitUdtInit(BasParserT *p, int32_t udtTypeId) {
    -    BasSymbolT *typeSym = findTypeDefById(p, udtTypeId);
    -
    -    if (!typeSym) {
    +// Store the temporary back into a SWAP operand.  Array elements re-parse
    +// their index list from the saved lexer position; the caller restores the
    +// lexer afterwards.
    +static void emitSwapStore(BasParserT *p, const SwapOperandT *op, BasSymbolT *tmp) {
    +    if (!op->isElement) {
    +        emitLoad(p, tmp);
    +        emitStore(p, op->sym);
             return;
         }
    -
    -    for (int32_t i = 0; i < typeSym->fieldCount; i++) {
    -        if (typeSym->fields[i].dataType != BAS_TYPE_UDT) {
    -            continue;
    -        }
    -
    -        int32_t childTypeId = typeSym->fields[i].udtTypeId;
    -        BasSymbolT *childType = findTypeDefById(p, childTypeId);
    -
    -        if (!childType) {
    -            continue;
    -        }
    -
    -        // DUP parent, allocate child UDT, STORE_FIELD
    -        basEmit8(&p->cg, OP_DUP);
    -        basEmit8(&p->cg, OP_PUSH_INT16);
    -        basEmit16(&p->cg, (int16_t)childTypeId);
    -        basEmit8(&p->cg, OP_PUSH_INT16);
    -        basEmit16(&p->cg, (int16_t)childType->fieldCount);
    -        basEmit8(&p->cg, OP_DIM_ARRAY);
    -        basEmit8(&p->cg, 0);
    -        basEmit8(&p->cg, BAS_TYPE_UDT);
    -
    -        // Recursively init the child's nested UDT fields
    -        emitUdtInit(p, childTypeId);
    -
    -        basEmit8(&p->cg, OP_STORE_FIELD);
    -        basEmitU16(&p->cg, (uint16_t)i);
    +    emitLoad(p, op->sym);
    +    p->lex = op->indexLex;
    +    int32_t dims = parseIndexList(p);
    +    emitLoad(p, tmp);
    +    if (op->sym->isTyped) {
    +        emitStoreConv(p, op->sym->dataType);
         }
    +    basEmit8(&p->cg, OP_STORE_ARRAY);
    +    basEmit8(&p->cg, (uint8_t)dims);
    +}
    +
    +
    +// Pushes the layout string of a TYPE as a constant.
    +static bool emitUdtLayout(BasParserT *p, int32_t typeId) {
    +    uint8_t layout[BAS_LAYOUT_MAX_LEN];
    +    int32_t layoutLen = 0;
    +
    +    if (!buildUdtLayout(p, typeId, layout, &layoutLen, 0)) {
    +        return false;
    +    }
    +
    +    uint16_t idx = basAddConstant(&p->cg, (const char *)layout, layoutLen);
    +    basEmit8(&p->cg, OP_PUSH_STR);
    +    basEmitU16(&p->cg, idx);
    +    return true;
    +}
    +
    +
    +// Pushes what OP_DIM_ARRAY / OP_REDIM need to build TYPE instances: the
    +// layout, the typeId and the field count.
    +static bool emitUdtSpec(BasParserT *p, int32_t typeId) {
    +    BasSymbolT *typeSym = (typeId >= 0) ? findTypeDefById(p, typeId) : NULL;
    +
    +    if (typeSym == NULL) {
    +        error(p, "Unknown TYPE definition");
    +        return false;
    +    }
    +
    +    if (!emitUdtLayout(p, typeId)) {
    +        return false;
    +    }
    +
    +    basEmit8(&p->cg, OP_PUSH_INT16);
    +    basEmit16(&p->cg, (int16_t)typeId);
    +    basEmit8(&p->cg, OP_PUSH_INT16);
    +    basEmit16(&p->cg, (int16_t)typeSym->fieldCount);
    +    return true;
     }
     
     
    @@ -1141,26 +1258,11 @@ static BasSymbolT *ensureVariable(BasParserT *p, const char *name) {
             return NULL;
         }
     
    -    // Auto-declare (QB implicit declaration)
    -    // Use suffix type if present, otherwise defType for the first letter
    -    uint8_t dt      = suffixToType(name);
    -    bool    isTyped = nameHasTypeSuffix(name);
    -
    -    if (!isTyped && name[0] != '\0') {
    -        // suffixToType returns SINGLE both for an explicit '!' suffix and
    -        // for no suffix at all.  Only apply the DEFxxx override when there
    -        // is NO explicit suffix; an explicit '!' must stay SINGLE.
    -        char firstLetter = (char)toupper((unsigned char)name[0]);
    -
    -        if (firstLetter >= 'A' && firstLetter <= 'Z') {
    -            uint8_t defDt = p->defType[firstLetter - 'A'];
    -
    -            if (defDt != BAS_DEFTYPE_NONE) {
    -                dt      = defDt;
    -                isTyped = true;
    -            }
    -        }
    -    }
    +    // Auto-declare (QB implicit declaration): suffix or DEFxxx type when
    +    // one applies, otherwise untyped (the variable keeps whatever type is
    +    // assigned to it).
    +    bool    isTyped;
    +    uint8_t dt = implicitType(p, name, &isTyped);
     
         sym = basSymTabAdd(&p->sym, name, SYM_VARIABLE, dt);
     
    @@ -1316,6 +1418,35 @@ static BasSymbolT *findTypeDefById(BasParserT *p, int32_t typeId) {
     }
     
     
    +// Type a variable takes without an AS clause: the suffix type when the
    +// name has one, else the DEFxxx type for its first letter, else the QB
    +// default (Single).  *outTyped reports whether a suffix or DEFxxx
    +// applied, i.e. whether stores must convert to the type.
    +static uint8_t implicitType(BasParserT *p, const char *name, bool *outTyped) {
    +    uint8_t dt = suffixToType(name);
    +
    +    *outTyped = nameHasTypeSuffix(name);
    +
    +    if (!*outTyped && name[0] != '\0') {
    +        // suffixToType returns SINGLE both for an explicit '!' suffix and
    +        // for no suffix at all.  Only apply the DEFxxx override when there
    +        // is NO explicit suffix; an explicit '!' must stay SINGLE.
    +        char firstLetter = (char)toupper((unsigned char)name[0]);
    +
    +        if (firstLetter >= 'A' && firstLetter <= 'Z') {
    +            uint8_t defDt = p->defType[firstLetter - 'A'];
    +
    +            if (defDt != BAS_DEFTYPE_NONE) {
    +                dt        = defDt;
    +                *outTyped = true;
    +            }
    +        }
    +    }
    +
    +    return dt;
    +}
    +
    +
     // Open a FOR (isFor) or DO/WHILE loop: start a fresh EXIT list, record
     // the SELECT depth EXIT must unwind to, and bump the loop depth that
     // makes a stray EXIT FOR / EXIT DO a compile error.
    @@ -1387,6 +1518,24 @@ static bool nameHasTypeSuffix(const char *name) {
     }
     
     
    +// Allocate a compiler temporary in the current scope.  The '$' prefix
    +// keeps the name out of reach of BASIC source and out of the debugger's
    +// variable lists.
    +static BasSymbolT *newTempVar(BasParserT *p) {
    +    char name[BAS_MAX_IDENT];
    +    snprintf(name, sizeof(name), "%s%d", BAS_TEMP_PREFIX, (int)p->tempCount++);
    +
    +    BasSymbolT *sym = basSymTabAdd(&p->sym, name, SYM_VARIABLE, BAS_TYPE_INTEGER);
    +    if (sym == NULL) {
    +        error(p, "Symbol table full");
    +        return NULL;
    +    }
    +    sym->index     = basSymTabAllocSlot(&p->sym);
    +    sym->isDefined = true;
    +    return sym;
    +}
    +
    +
     static void parseAddExpr(BasParserT *p) {
         parseMulExpr(p);
         while (!p->hasError) {
    @@ -1476,51 +1625,7 @@ static void parseAssignOrCall(BasParserT *p) {
             // Check for UDT field access first
             if (sym != NULL && sym->dataType == BAS_TYPE_UDT && sym->udtTypeId >= 0) {
                 emitLoad(p, sym);
    -            int32_t curTypeId = sym->udtTypeId;
    -
    -            // Walk the dot chain: a.b.c = expr
    -            // For intermediate fields, emit LOAD_FIELD (navigate into nested UDT).
    -            // For the final field, emit STORE_FIELD with the assigned value.
    -            while (check(p, TOK_DOT) && curTypeId >= 0) {
    -                advance(p); // consume DOT
    -                if (!check(p, TOK_IDENT)) {
    -                    errorExpected(p, "field name");
    -                    return;
    -                }
    -                BasSymbolT *typeSym = findTypeDefById(p, curTypeId);
    -                if (typeSym == NULL) {
    -                    error(p, "Unknown TYPE definition");
    -                    return;
    -                }
    -                int32_t fieldIdx = resolveFieldIndex(typeSym, p->lex.token.text);
    -                if (fieldIdx < 0) {
    -                    char buf[BAS_PARSE_ERR_SCRATCH];
    -                    snprintf(buf, sizeof(buf), "Unknown field '%s' in TYPE '%s'", p->lex.token.text, typeSym->name);
    -                    error(p, buf);
    -                    return;
    -                }
    -                advance(p); // consume field name
    -
    -                // Check if this field is a nested UDT with more dots coming
    -                bool fieldIsUdt = (typeSym->fields[fieldIdx].dataType == BAS_TYPE_UDT);
    -
    -                if (fieldIsUdt && check(p, TOK_DOT)) {
    -                    // Intermediate level: load this field and continue
    -                    basEmit8(&p->cg, OP_LOAD_FIELD);
    -                    basEmitU16(&p->cg, (uint16_t)fieldIdx);
    -                    curTypeId = typeSym->fields[fieldIdx].udtTypeId;
    -                } else {
    -                    // Final field: store value
    -                    expect(p, TOK_EQ);
    -                    parseExpression(p);
    -                    emitStoreConv(p, typeSym->fields[fieldIdx].dataType);
    -                    basEmit8(&p->cg, OP_STORE_FIELD);
    -                    basEmitU16(&p->cg, (uint16_t)fieldIdx);
    -                    return;
    -                }
    -            }
    -
    -            error(p, "Expected '=' in UDT field assignment");
    +            parseFieldStoreChain(p, sym->udtTypeId);
                 return;
             }
     
    @@ -1547,6 +1652,41 @@ static void parseAssignOrCall(BasParserT *p) {
             // name.
             bool isVarRef = (sym != NULL && sym->kind == SYM_VARIABLE);
     
    +        // Three-part access: FormName.Ctrl.Prop = expr / FormName.Ctrl.Method
    +        // (or formVar.Ctrl...).  The control lives on the named form.
    +        if (check(p, TOK_DOT)) {
    +            if (isVarRef) {
    +                emitLoad(p, sym);
    +            } else {
    +                uint16_t formIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
    +                basEmit8(&p->cg, OP_PUSH_STR);
    +                basEmitU16(&p->cg, formIdx);
    +                basEmit8(&p->cg, OP_LOAD_FORM);
    +            }
    +            uint16_t ctrlIdx = basAddConstant(&p->cg, memberName, (int32_t)strlen(memberName));
    +            basEmit8(&p->cg, OP_PUSH_STR);
    +            basEmitU16(&p->cg, ctrlIdx);
    +            basEmit8(&p->cg, OP_FIND_CTRL);
    +            advance(p); // consume DOT
    +
    +            if (!isalpha((unsigned char)p->lex.token.text[0]) && p->lex.token.text[0] != '_') {
    +                errorExpected(p, "property or method name");
    +                return;
    +            }
    +            uint16_t propIdx = basAddConstant(&p->cg, p->lex.token.text, (int32_t)strlen(p->lex.token.text));
    +            advance(p);
    +            basEmit8(&p->cg, OP_PUSH_STR);
    +            basEmitU16(&p->cg, propIdx);
    +
    +            if (match(p, TOK_EQ)) {
    +                parseExpression(p);
    +                basEmit8(&p->cg, OP_STORE_PROP);
    +            } else {
    +                emitMethodCallStatement(p);
    +            }
    +            return;
    +        }
    +
             // Special form methods: Show, Hide
             if (strcasecmp(memberName, "Show") == 0) {
                 // name.Show [modal]
    @@ -1721,17 +1861,13 @@ static void parseAssignOrCall(BasParserT *p) {
     
             emitLoad(p, sym);
             expect(p, TOK_LPAREN);
    -        int32_t dims = 0;
    -        parseExpression(p);
    -        dims++;
    -        while (match(p, TOK_COMMA)) {
    -            parseExpression(p);
    -            dims++;
    -        }
    -        expect(p, TOK_RPAREN);
    +        int32_t dims = parseIndexList(p);
     
    -        // Array-of-UDT field store: arr(i).field = expr
    +        // Array-of-UDT field store: arr(i).field = expr, or a nested
    +        // arr(i).field.sub = expr (the element is loaded and the chain
    +        // stored through it -- TYPE elements are shared references).
             if (sym->dataType == BAS_TYPE_UDT && sym->udtTypeId >= 0 && check(p, TOK_DOT)) {
    +            BasLexerT dotLex = p->lex;
                 advance(p); // consume DOT
                 if (!check(p, TOK_IDENT)) {
                     errorExpected(p, "field name");
    @@ -1750,9 +1886,16 @@ static void parseAssignOrCall(BasParserT *p) {
                     return;
                 }
                 advance(p); // consume field name
    +            if (check(p, TOK_DOT)) {
    +                basEmit8(&p->cg, OP_LOAD_ARRAY);
    +                basEmit8(&p->cg, (uint8_t)dims);
    +                p->lex = dotLex;
    +                parseFieldStoreChain(p, sym->udtTypeId);
    +                return;
    +            }
                 expect(p, TOK_EQ);
                 parseExpression(p);
    -            emitStoreConv(p, typeSym->fields[fieldIdx].dataType);
    +            emitFieldStoreConv(p, &typeSym->fields[fieldIdx]);
                 basEmit8(&p->cg, OP_STORE_ARRAY_FIELD);
                 basEmit8(&p->cg, (uint8_t)dims);
                 basEmitU16(&p->cg, (uint16_t)fieldIdx);
    @@ -2517,43 +2660,39 @@ static void parseDim(BasParserT *p) {
         name[BAS_MAX_TOKEN_LEN - 1] = '\0';
         advance(p);
     
    -    bool isArray = false;
    -    int32_t dims = 0;
    +    bool    isArray   = false;
    +    bool    isDynamic = false;
    +    int32_t dims      = 0;
     
    -    // Check for array bounds
    +    // Check for array bounds.  "name()" declares a dynamic array that is
    +    // sized by a later REDIM.
         if (check(p, TOK_LPAREN)) {
             isArray = true;
             advance(p);
    -        parseDimBounds(p, &dims);
    +        if (check(p, TOK_RPAREN)) {
    +            isDynamic = true;
    +        } else {
    +            parseDimBounds(p, &dims);
    +        }
             expect(p, TOK_RPAREN);
         }
     
    -    // Optional AS type
    -    uint8_t dt        = suffixToType(name);
    -    bool    isTyped   = nameHasTypeSuffix(name);
    +    // Optional AS type.  Without one the suffix or DEFxxx type applies,
    +    // else the QB default (Single) -- either way the variable is typed.
    +    bool    isTyped;
    +    uint8_t dt        = implicitType(p, name, &isTyped);
         int32_t udtTypeId = -1;
         int32_t fixedLen  = 0;
         if (match(p, TOK_AS)) {
    -        dt      = resolveTypeName(p);
    -        isTyped = true;
    +        dt = resolveTypeName(p);
             if (dt == BAS_TYPE_UDT) {
                 udtTypeId = p->lastUdtTypeId;
             }
    -        // Check for STRING * n (fixed-length string)
    -        if (dt == BAS_TYPE_STRING && check(p, TOK_STAR)) {
    -            advance(p);
    -            if (!check(p, TOK_INT_LIT)) {
    -                error(p, "Expected integer after STRING *");
    -            } else if (p->lex.token.intVal < 1 || p->lex.token.intVal > UINT16_MAX) {
    -                char buf[BAS_PARSE_ERR_SCRATCH];
    -                snprintf(buf, sizeof(buf), "STRING * length must be 1 to %d", (int)UINT16_MAX);
    -                error(p, buf);
    -            } else {
    -                fixedLen = p->lex.token.intVal;
    -                advance(p);
    -            }
    +        if (dt == BAS_TYPE_STRING) {
    +            fixedLen = parseFixedStringLen(p);
             }
         }
    +    isTyped = true;
     
         if (p->hasError) {
             return;
    @@ -2607,6 +2746,7 @@ static void parseDim(BasParserT *p) {
         sym->index     = basSymTabAllocSlot(&p->sym);
         sym->isDefined = true;
         sym->isArray   = isArray;
    +    sym->isDynamic = isDynamic;
         sym->isShared  = isShared;
         sym->udtTypeId = udtTypeId;
         sym->fixedLen  = fixedLen;
    @@ -2614,38 +2754,28 @@ static void parseDim(BasParserT *p) {
     
         sym->scope = newScope;
     
    +    if (isDynamic) {
    +        // Nothing to allocate until REDIM.
    +        return;
    +    }
    +
         if (isArray) {
    -        if (dt == BAS_TYPE_UDT && udtTypeId >= 0) {
    -            // For UDT arrays, push typeId and fieldCount so elements
    -            // can be properly initialized
    -            BasSymbolT *typeSym = findTypeDefById(p, udtTypeId);
    -            if (typeSym != NULL) {
    -                basEmit8(&p->cg, OP_PUSH_INT16);
    -                basEmit16(&p->cg, (int16_t)udtTypeId);
    -                basEmit8(&p->cg, OP_PUSH_INT16);
    -                basEmit16(&p->cg, (int16_t)typeSym->fieldCount);
    -            }
    +        if (dt == BAS_TYPE_UDT && udtTypeId >= 0 && !emitUdtSpec(p, udtTypeId)) {
    +            return;
             }
             basEmit8(&p->cg, OP_DIM_ARRAY);
             basEmit8(&p->cg, (uint8_t)dims);
             basEmit8(&p->cg, dt);
             emitStore(p, sym);
         } else if (dt == BAS_TYPE_UDT && udtTypeId >= 0) {
    -        // Allocate a UDT instance
    -        BasSymbolT *typeSym = findTypeDefById(p, udtTypeId);
    -        if (typeSym != NULL) {
    -            basEmit8(&p->cg, OP_PUSH_INT16);
    -            basEmit16(&p->cg, (int16_t)udtTypeId);
    -            basEmit8(&p->cg, OP_PUSH_INT16);
    -            basEmit16(&p->cg, (int16_t)typeSym->fieldCount);
    -            // OP_DIM_ARRAY with dims=0 signals UDT allocation
    -            basEmit8(&p->cg, OP_DIM_ARRAY);
    -            basEmit8(&p->cg, 0);
    -            basEmit8(&p->cg, BAS_TYPE_UDT);
    -            // Initialize nested UDT fields
    -            emitUdtInit(p, udtTypeId);
    -            emitStore(p, sym);
    +        // Allocate a UDT instance: OP_DIM_ARRAY with dims=0
    +        if (!emitUdtSpec(p, udtTypeId)) {
    +            return;
             }
    +        basEmit8(&p->cg, OP_DIM_ARRAY);
    +        basEmit8(&p->cg, 0);
    +        basEmit8(&p->cg, BAS_TYPE_UDT);
    +        emitStore(p, sym);
         } else if (dt == BAS_TYPE_STRING) {
             // STRING slots must start as an empty string, not numeric 0.
             // Arithmetic falls through basValToNumber so numeric defaults
    @@ -2860,6 +2990,7 @@ static void parseErase(BasParserT *p) {
     
         emitLoad(p, sym);
         basEmit8(&p->cg, OP_ERASE);
    +    basEmit8(&p->cg, sym->isDynamic ? BAS_ERASE_FREE : BAS_ERASE_ZERO);
         emitStore(p, sym);
     }
     
    @@ -2925,6 +3056,80 @@ static void parseExpression(BasParserT *p) {
     }
     
     
    +// Field-access chain after a TYPE value has been pushed: .a.b.c loads
    +// each field in turn (stops when a field is not itself a TYPE).
    +static void parseFieldLoadChain(BasParserT *p, int32_t typeId) {
    +    while (check(p, TOK_DOT) && typeId >= 0) {
    +        advance(p); // consume DOT
    +        if (!check(p, TOK_IDENT)) {
    +            errorExpected(p, "field name");
    +            return;
    +        }
    +        BasSymbolT *typeSym = findTypeDefById(p, typeId);
    +        if (typeSym == NULL) {
    +            error(p, "Unknown TYPE definition");
    +            return;
    +        }
    +        int32_t fieldIdx = resolveFieldIndex(typeSym, p->lex.token.text);
    +        if (fieldIdx < 0) {
    +            char buf[BAS_PARSE_ERR_SCRATCH];
    +            snprintf(buf, sizeof(buf), "Unknown field '%s' in TYPE '%s'", p->lex.token.text, typeSym->name);
    +            error(p, buf);
    +            return;
    +        }
    +        advance(p); // consume field name
    +        basEmit8(&p->cg, OP_LOAD_FIELD);
    +        basEmitU16(&p->cg, (uint16_t)fieldIdx);
    +
    +        typeId = (typeSym->fields[fieldIdx].dataType == BAS_TYPE_UDT) ? typeSym->fields[fieldIdx].udtTypeId : -1;
    +    }
    +}
    +
    +
    +// Field-assignment chain after a TYPE value has been pushed: .a.b = expr
    +// navigates the intermediate fields with LOAD_FIELD and stores the
    +// value into the final one.
    +static void parseFieldStoreChain(BasParserT *p, int32_t typeId) {
    +    while (check(p, TOK_DOT) && typeId >= 0) {
    +        advance(p); // consume DOT
    +        if (!check(p, TOK_IDENT)) {
    +            errorExpected(p, "field name");
    +            return;
    +        }
    +        BasSymbolT *typeSym = findTypeDefById(p, typeId);
    +        if (typeSym == NULL) {
    +            error(p, "Unknown TYPE definition");
    +            return;
    +        }
    +        int32_t fieldIdx = resolveFieldIndex(typeSym, p->lex.token.text);
    +        if (fieldIdx < 0) {
    +            char buf[BAS_PARSE_ERR_SCRATCH];
    +            snprintf(buf, sizeof(buf), "Unknown field '%s' in TYPE '%s'", p->lex.token.text, typeSym->name);
    +            error(p, buf);
    +            return;
    +        }
    +        advance(p); // consume field name
    +
    +        if (typeSym->fields[fieldIdx].dataType == BAS_TYPE_UDT && check(p, TOK_DOT)) {
    +            // Intermediate level: load this field and continue
    +            basEmit8(&p->cg, OP_LOAD_FIELD);
    +            basEmitU16(&p->cg, (uint16_t)fieldIdx);
    +            typeId = typeSym->fields[fieldIdx].udtTypeId;
    +            continue;
    +        }
    +
    +        expect(p, TOK_EQ);
    +        parseExpression(p);
    +        emitFieldStoreConv(p, &typeSym->fields[fieldIdx]);
    +        basEmit8(&p->cg, OP_STORE_FIELD);
    +        basEmitU16(&p->cg, (uint16_t)fieldIdx);
    +        return;
    +    }
    +
    +    error(p, "Expected '=' in UDT field assignment");
    +}
    +
    +
     static void parseFileCopy(BasParserT *p) {
         // FILECOPY source, dest
         advance(p);
    @@ -2935,6 +3140,28 @@ static void parseFileCopy(BasParserT *p) {
     }
     
     
    +// The "* n" after STRING in a declaration.  Returns the length, or 0
    +// when no '*' follows (variable-length string).
    +static int32_t parseFixedStringLen(BasParserT *p) {
    +    if (!match(p, TOK_STAR)) {
    +        return 0;
    +    }
    +    if (!check(p, TOK_INT_LIT)) {
    +        error(p, "Expected integer after STRING *");
    +        return 0;
    +    }
    +    if (p->lex.token.intVal < 1 || p->lex.token.intVal > UINT16_MAX) {
    +        char buf[BAS_PARSE_ERR_SCRATCH];
    +        snprintf(buf, sizeof(buf), "STRING * length must be 1 to %d", (int)UINT16_MAX);
    +        error(p, buf);
    +        return 0;
    +    }
    +    int32_t fixedLen = p->lex.token.intVal;
    +    advance(p);
    +    return fixedLen;
    +}
    +
    +
     static void parseFor(BasParserT *p) {
         // FOR var = start TO limit [STEP step]
         //   ...
    @@ -3201,10 +3428,22 @@ static void parseGet(BasParserT *p) {
             return;
         }
     
    +    if (sym->isArray) {
    +        parseGetArrayElement(p, sym);
    +        return;
    +    }
    +
         // A STRING target reads as many bytes as it already holds in BINARY
    -    // mode, so its current value goes on the stack ahead of the type.
    +    // mode, so its current value goes on the stack ahead of the type.  A
    +    // TYPE target is filled in place: the instance and its field layout
    +    // go on the stack.
         if (sym->dataType == BAS_TYPE_STRING) {
             emitLoad(p, sym);
    +    } else if (sym->dataType == BAS_TYPE_UDT) {
    +        emitLoad(p, sym);
    +        if (!emitUdtLayout(p, sym->udtTypeId)) {
    +            return;
    +        }
         }
     
         // Push variable type so VM knows how many bytes to read
    @@ -3217,6 +3456,65 @@ static void parseGet(BasParserT *p) {
     }
     
     
    +// GET #channel, recno, arr(i).  The channel and record number are already
    +// on the stack, so the element cannot simply be stored with OP_STORE_ARRAY
    +// (that needs the array and indices beneath the value).  A TYPE element
    +// is filled in place through its shared instance; a scalar element goes
    +// through a compiler temporary and a second index evaluation.
    +static void parseGetArrayElement(BasParserT *p, BasSymbolT *sym) {
    +    if (!check(p, TOK_LPAREN)) {
    +        errorExpected(p, "array index");
    +        return;
    +    }
    +    advance(p);
    +
    +    BasLexerT indexLex = p->lex;
    +    emitLoad(p, sym);
    +    int32_t dims = parseIndexList(p);
    +    basEmit8(&p->cg, OP_LOAD_ARRAY);
    +    basEmit8(&p->cg, (uint8_t)dims);
    +
    +    if (sym->dataType == BAS_TYPE_UDT) {
    +        if (!emitUdtLayout(p, sym->udtTypeId)) {
    +            return;
    +        }
    +        basEmit8(&p->cg, OP_PUSH_INT16);
    +        basEmit16(&p->cg, (int16_t)BAS_TYPE_UDT);
    +        basEmit8(&p->cg, OP_FILE_GET);
    +        basEmit8(&p->cg, OP_POP);
    +        return;
    +    }
    +
    +    // Only a STRING element needs its current value (BINARY read length);
    +    // the load above still bounds-checks the subscript for the others.
    +    if (sym->dataType != BAS_TYPE_STRING) {
    +        basEmit8(&p->cg, OP_POP);
    +    }
    +
    +    basEmit8(&p->cg, OP_PUSH_INT16);
    +    basEmit16(&p->cg, (int16_t)sym->dataType);
    +    basEmit8(&p->cg, OP_FILE_GET);
    +
    +    BasSymbolT *tmp = newTempVar(p);
    +    if (tmp == NULL) {
    +        return;
    +    }
    +    emitStore(p, tmp);
    +
    +    BasLexerT afterLex = p->lex;
    +    emitLoad(p, sym);
    +    p->lex = indexLex;
    +    parseIndexList(p);
    +    p->lex = afterLex;
    +    emitLoad(p, tmp);
    +    if (sym->isTyped) {
    +        emitStoreConv(p, sym->dataType);
    +    }
    +    basEmit8(&p->cg, OP_STORE_ARRAY);
    +    basEmit8(&p->cg, (uint8_t)dims);
    +}
    +
    +
     static void parseGosub(BasParserT *p) {
         // GOSUB label -- push return PC, then JMP to label
         advance(p); // consume GOSUB
    @@ -3449,6 +3747,21 @@ static void parseImpExpr(BasParserT *p) {
     }
     
     
    +// Comma-separated subscript list after an opening '(' (already
    +// consumed) up to and including the closing ')'.  Returns the count.
    +static int32_t parseIndexList(BasParserT *p) {
    +    int32_t dims = 0;
    +    parseExpression(p);
    +    dims++;
    +    while (match(p, TOK_COMMA)) {
    +        parseExpression(p);
    +        dims++;
    +    }
    +    expect(p, TOK_RPAREN);
    +    return dims;
    +}
    +
    +
     static void parseInput(BasParserT *p) {
         // INPUT #channel, var
         // INPUT [prompt;] var
    @@ -3702,6 +4015,65 @@ static void parseNotExpr(BasParserT *p) {
     }
     
     
    +// Expression-context member chain on the object reference already on the
    +// stack; the current token is the DOT.  Handles .Prop, .Method(args),
    +// .Ctrl.Member and .Ctrl(idx).Member.  A parenthesised list followed by
    +// another DOT is a control-array index (the ref, name, index order is the
    +// same one OP_CALL_METHOD wants, so no lookahead is needed).
    +static void parseObjectMemberExpr(BasParserT *p) {
    +    advance(p); // consume DOT
    +
    +    if (!isalpha((unsigned char)p->lex.token.text[0]) && p->lex.token.text[0] != '_') {
    +        errorExpected(p, "property or method name");
    +        return;
    +    }
    +
    +    uint16_t nameIdx = basAddConstant(&p->cg, p->lex.token.text, (int32_t)strlen(p->lex.token.text));
    +    advance(p);
    +    basEmit8(&p->cg, OP_PUSH_STR);
    +    basEmitU16(&p->cg, nameIdx);
    +
    +    if (check(p, TOK_DOT)) {
    +        basEmit8(&p->cg, OP_FIND_CTRL);
    +        parseObjectMemberExpr(p);
    +        return;
    +    }
    +
    +    if (!check(p, TOK_LPAREN)) {
    +        basEmit8(&p->cg, OP_LOAD_PROP);
    +        return;
    +    }
    +
    +    advance(p); // consume '('
    +    int32_t argc = 0;
    +    if (!check(p, TOK_RPAREN)) {
    +        parseExpression(p);
    +        argc++;
    +        while (match(p, TOK_COMMA)) {
    +            parseExpression(p);
    +            argc++;
    +        }
    +    }
    +    expect(p, TOK_RPAREN);
    +
    +    if (check(p, TOK_DOT)) {
    +        if (argc != 1) {
    +            error(p, "Control array index expected");
    +            return;
    +        }
    +        basEmit8(&p->cg, OP_FIND_CTRL_IDX);
    +        parseObjectMemberExpr(p);
    +        return;
    +    }
    +
    +    if (clampArgCount(p, argc)) {
    +        return;
    +    }
    +    basEmit8(&p->cg, OP_CALL_METHOD);
    +    basEmit8(&p->cg, (uint8_t)argc);
    +}
    +
    +
     static void parseOn(BasParserT *p) {
         // ON ERROR GOTO label -- error handler
         // ON expr GOTO label1, label2, ... -- computed goto
    @@ -4030,7 +4402,9 @@ static bool parseParamList(BasParserT *p, ParamListT *pl, uint32_t flags) {
                 return false;
             }
     
    -        bool byVal = match(p, TOK_BYVAL) || (flags & PARAM_FORCE_BYVAL) != 0;
    +        // BYREF is the default and may be written out for clarity.
    +        bool byRef = match(p, TOK_BYREF);
    +        bool byVal = (!byRef && match(p, TOK_BYVAL)) || (flags & PARAM_FORCE_BYVAL) != 0;
     
             if (!check(p, TOK_IDENT)) {
                 errorExpected(p, "parameter name");
    @@ -4046,6 +4420,15 @@ static bool parseParamList(BasParserT *p, ParamListT *pl, uint32_t flags) {
             paramName[BAS_MAX_TOKEN_LEN - 1] = '\0';
             advance(p);
     
    +        // "name()" declares an array parameter; the caller passes the
    +        // array by reference.
    +        bool isArrayParam = false;
    +
    +        if (match(p, TOK_LPAREN)) {
    +            expect(p, TOK_RPAREN);
    +            isArrayParam = true;
    +        }
    +
             uint8_t pdt        = suffixToType(paramName);
             bool    pTyped     = nameHasTypeSuffix(paramName);
             int32_t pUdtTypeId = -1;
    @@ -4074,6 +4457,8 @@ static bool parseParamList(BasParserT *p, ParamListT *pl, uint32_t flags) {
                 paramSym->isDefined = true;
                 paramSym->udtTypeId = pUdtTypeId;
                 paramSym->isTyped   = pTyped;
    +            paramSym->isArray   = isArrayParam;
    +            paramSym->isDynamic = isArrayParam;
             }
     
             pl->types[pl->count]    = pdt;
    @@ -4207,10 +4592,14 @@ static void parsePrimary(BasParserT *p) {
             return;
         }
     
    -    // Me -- reference to current form
    +    // Me -- reference to current form, optionally Me.Member,
    +    // Me.Ctrl.Member or Me.Ctrl(idx).Member
         if (tt == TOK_ME) {
             advance(p);
             basEmit8(&p->cg, OP_ME_REF);
    +        if (check(p, TOK_DOT)) {
    +            parseObjectMemberExpr(p);
    +        }
             return;
         }
     
    @@ -4275,7 +4664,7 @@ static void parsePrimary(BasParserT *p) {
                 expect(p, TOK_LPAREN);
                 parseExpression(p);
                 expect(p, TOK_RPAREN);
    -            basEmit8(&p->cg, OP_FILE_LOC);
    +            basEmit8(&p->cg, OP_FILE_SEEK_GET);
                 return;
             }
             // Not a function call -- error (SEEK as statement is handled elsewhere)
    @@ -4297,6 +4686,13 @@ static void parsePrimary(BasParserT *p) {
             return;
         }
     
    +    // ERL -- source line of the current error
    +    if (tt == TOK_ERL) {
    +        advance(p);
    +        basEmit8(&p->cg, OP_ERL);
    +        return;
    +    }
    +
         // CurDir$ -- current directory (no args)
         if (tt == TOK_CURDIR) {
             advance(p);
    @@ -4560,39 +4956,13 @@ static void parsePrimary(BasParserT *p) {
                 if (sym != NULL && sym->isArray) {
                     emitLoad(p, sym);
                     expect(p, TOK_LPAREN);
    -                int32_t dims = 0;
    -                parseExpression(p);
    -                dims++;
    -                while (match(p, TOK_COMMA)) {
    -                    parseExpression(p);
    -                    dims++;
    -                }
    -                expect(p, TOK_RPAREN);
    +                int32_t dims = parseIndexList(p);
                     basEmit8(&p->cg, OP_LOAD_ARRAY);
                     basEmit8(&p->cg, (uint8_t)dims);
     
    -                // Array-of-UDT field access: arr(i).field
    +                // Array-of-UDT field access: arr(i).field[.sub...]
                     if (sym->dataType == BAS_TYPE_UDT && sym->udtTypeId >= 0 && check(p, TOK_DOT)) {
    -                    advance(p); // consume DOT
    -                    if (!check(p, TOK_IDENT)) {
    -                        errorExpected(p, "field name");
    -                        return;
    -                    }
    -                    BasSymbolT *typeSym = findTypeDefById(p, sym->udtTypeId);
    -                    if (typeSym == NULL) {
    -                        error(p, "Unknown TYPE definition");
    -                        return;
    -                    }
    -                    int32_t fieldIdx = resolveFieldIndex(typeSym, p->lex.token.text);
    -                    if (fieldIdx < 0) {
    -                        char buf[BAS_PARSE_ERR_SCRATCH];
    -                        snprintf(buf, sizeof(buf), "Unknown field '%s' in TYPE '%s'", p->lex.token.text, typeSym->name);
    -                        error(p, buf);
    -                        return;
    -                    }
    -                    advance(p); // consume field name
    -                    basEmit8(&p->cg, OP_LOAD_FIELD);
    -                    basEmitU16(&p->cg, (uint16_t)fieldIdx);
    +                    parseFieldLoadChain(p, sym->udtTypeId);
                     }
                     return;
                 }
    @@ -4650,38 +5020,7 @@ static void parsePrimary(BasParserT *p) {
                 sym = basSymTabFind(&p->sym, name);
                 if (sym != NULL && sym->dataType == BAS_TYPE_UDT && sym->udtTypeId >= 0) {
                     emitLoad(p, sym);
    -                int32_t curTypeId = sym->udtTypeId;
    -
    -                // Loop to handle nested UDT field access: a.b.c
    -                while (check(p, TOK_DOT) && curTypeId >= 0) {
    -                    advance(p); // consume DOT
    -                    if (!check(p, TOK_IDENT)) {
    -                        errorExpected(p, "field name");
    -                        return;
    -                    }
    -                    BasSymbolT *typeSym = findTypeDefById(p, curTypeId);
    -                    if (typeSym == NULL) {
    -                        error(p, "Unknown TYPE definition");
    -                        return;
    -                    }
    -                    int32_t fieldIdx = resolveFieldIndex(typeSym, p->lex.token.text);
    -                    if (fieldIdx < 0) {
    -                        char buf[BAS_PARSE_ERR_SCRATCH];
    -                        snprintf(buf, sizeof(buf), "Unknown field '%s' in TYPE '%s'", p->lex.token.text, typeSym->name);
    -                        error(p, buf);
    -                        return;
    -                    }
    -                    advance(p); // consume field name
    -                    basEmit8(&p->cg, OP_LOAD_FIELD);
    -                    basEmitU16(&p->cg, (uint16_t)fieldIdx);
    -
    -                    // If this field is also a UDT, allow further dot access
    -                    if (typeSym->fields[fieldIdx].dataType == BAS_TYPE_UDT) {
    -                        curTypeId = typeSym->fields[fieldIdx].udtTypeId;
    -                    } else {
    -                        curTypeId = -1;
    -                    }
    -                }
    +                parseFieldLoadChain(p, sym->udtTypeId);
                     return;
                 }
     
    @@ -4698,6 +5037,26 @@ static void parsePrimary(BasParserT *p) {
     
                 bool isVarRef2 = (sym != NULL && sym->kind == SYM_VARIABLE);
     
    +            // Three-part access: FormName.Ctrl.Member or formVar.Ctrl.Member.
    +            // The first part names (or holds) a form; the control is
    +            // looked up on it rather than on the current form.
    +            if (check(p, TOK_DOT)) {
    +                if (isVarRef2) {
    +                    emitLoad(p, sym);
    +                } else {
    +                    uint16_t formIdx = basAddConstant(&p->cg, name, (int32_t)strlen(name));
    +                    basEmit8(&p->cg, OP_PUSH_STR);
    +                    basEmitU16(&p->cg, formIdx);
    +                    basEmit8(&p->cg, OP_LOAD_FORM);
    +                }
    +                uint16_t ctrlIdx = basAddConstant(&p->cg, memberName, (int32_t)strlen(memberName));
    +                basEmit8(&p->cg, OP_PUSH_STR);
    +                basEmitU16(&p->cg, ctrlIdx);
    +                basEmit8(&p->cg, OP_FIND_CTRL);
    +                parseObjectMemberExpr(p);
    +                return;
    +            }
    +
                 // Compile-time validation for expression-context reads /
                 // method returns.  Peek ahead: if '(' follows, memberName
                 // is a method; otherwise it's a property read.  Skip when
    @@ -4862,7 +5221,13 @@ static void parsePrint(BasParserT *p) {
             // Semicolon separates format from values
             expect(p, TOK_SEMICOLON);
     
    -        // Parse values, each one gets formatted with PRINT_USING
    +        // Parse values, each one gets formatted with PRINT_USING.  The
    +        // first value rewinds the format scan; the last one is flagged so
    +        // the trailing literal text is printed too (patched in once the
    +        // separator after it is known).
    +        uint8_t flags    = BAS_USING_FIRST;
    +        int32_t flagsPos = -1;
    +
             for (;;) {
                 // Bail on error: a stuck ';' would re-satisfy the separator
                 // check below and spin (advance() is a no-op under error).
    @@ -4872,7 +5237,10 @@ static void parsePrint(BasParserT *p) {
     
                 parseExpression(p);
                 basEmit8(&p->cg, OP_PRINT_USING);
    +            flagsPos = basCodePos(&p->cg);
    +            basEmit8(&p->cg, flags);
                 basEmit8(&p->cg, OP_PRINT);
    +            flags = 0;
     
                 if (check(p, TOK_SEMICOLON)) {
                     advance(p);
    @@ -4885,6 +5253,10 @@ static void parsePrint(BasParserT *p) {
                 break;
             }
     
    +        if (flagsPos >= 0) {
    +            p->cg.code[flagsPos] |= BAS_USING_LAST;
    +        }
    +
             // The format string pushed at the top of this branch is re-pushed by
             // each OP_PRINT_USING and left on the stack after the final OP_PRINT;
             // pop it so PRINT USING doesn't leak one eval slot per execution.
    @@ -4980,7 +5352,54 @@ static void parsePut(BasParserT *p) {
         }
         expect(p, TOK_COMMA);
     
    -    // Value expression
    +    // A bare TYPE variable writes its packed fields; anything else is a
    +    // value expression.
    +    if (check(p, TOK_IDENT)) {
    +        BasSymbolT *sym = basSymTabFind(&p->sym, p->lex.token.text);
    +
    +        if (sym != NULL && sym->kind == SYM_VARIABLE && sym->dataType == BAS_TYPE_UDT) {
    +            BasLexerT savedLex = p->lex;
    +            advance(p);
    +
    +            if (sym->isArray) {
    +                // arr(i) writes the element's packed fields; arr(i).field
    +                // is an ordinary value expression.
    +                if (check(p, TOK_LPAREN)) {
    +                    advance(p);
    +                    emitLoad(p, sym);
    +                    int32_t dims = parseIndexList(p);
    +                    basEmit8(&p->cg, OP_LOAD_ARRAY);
    +                    basEmit8(&p->cg, (uint8_t)dims);
    +
    +                    if (check(p, TOK_DOT)) {
    +                        parseFieldLoadChain(p, sym->udtTypeId);
    +                        basEmit8(&p->cg, OP_FILE_PUT);
    +                        return;
    +                    }
    +
    +                    if (!emitUdtLayout(p, sym->udtTypeId)) {
    +                        return;
    +                    }
    +                    basEmit8(&p->cg, OP_FILE_PUT_UDT);
    +                    return;
    +                }
    +            } else {
    +                bool bare = check(p, TOK_NEWLINE) || check(p, TOK_EOF) || check(p, TOK_COLON) || check(p, TOK_ELSE);
    +
    +                if (bare) {
    +                    emitLoad(p, sym);
    +                    if (!emitUdtLayout(p, sym->udtTypeId)) {
    +                        return;
    +                    }
    +                    basEmit8(&p->cg, OP_FILE_PUT_UDT);
    +                    return;
    +                }
    +            }
    +
    +            p->lex = savedLex;
    +        }
    +    }
    +
         parseExpression(p);
     
         basEmit8(&p->cg, OP_FILE_PUT);
    @@ -5053,6 +5472,9 @@ static void parseRedim(BasParserT *p) {
             error(p, buf);
             return;
         }
    +    if (!sym->isArray) {
    +        sym->isDynamic = true;
    +    }
         sym->isArray = true;
     
         // Load the old array reference
    @@ -5080,18 +5502,11 @@ static void parseRedim(BasParserT *p) {
             return;
         }
     
    -    // UDT element arrays carry typeId and fieldCount after the bounds,
    -    // exactly as OP_DIM_ARRAY does, so REDIM can seed every element.
    -    if (dt == BAS_TYPE_UDT) {
    -        BasSymbolT *typeSym = (udtTypeId >= 0) ? findTypeDefById(p, udtTypeId) : NULL;
    -        if (typeSym == NULL) {
    -            error(p, "Unknown TYPE definition");
    -            return;
    -        }
    -        basEmit8(&p->cg, OP_PUSH_INT16);
    -        basEmit16(&p->cg, (int16_t)udtTypeId);
    -        basEmit8(&p->cg, OP_PUSH_INT16);
    -        basEmit16(&p->cg, (int16_t)typeSym->fieldCount);
    +    // UDT element arrays carry the layout, typeId and fieldCount after
    +    // the bounds, exactly as OP_DIM_ARRAY does, so REDIM can seed every
    +    // element.
    +    if (dt == BAS_TYPE_UDT && !emitUdtSpec(p, udtTypeId)) {
    +        return;
         }
     
         basEmit8(&p->cg, OP_REDIM);
    @@ -5836,8 +6251,13 @@ static void parseStatement(BasParserT *p) {
                 break;
     
             case TOK_UNLOAD:
    -            // Unload FormName (identifier, not string)
    +            // Unload Me / Unload FormName (identifier, not string)
                 advance(p);
    +            if (match(p, TOK_ME)) {
    +                basEmit8(&p->cg, OP_ME_REF);
    +                basEmit8(&p->cg, OP_UNLOAD_FORM);
    +                break;
    +            }
                 if (!check(p, TOK_IDENT)) {
                     errorExpected(p, "form name");
                     break;
    @@ -6308,48 +6728,92 @@ static void parseSub(BasParserT *p) {
     
     
     static void parseSwap(BasParserT *p) {
    -    // SWAP a, b -- swap the values of two variables
    +    // SWAP a, b -- exchange two variables or array elements
         advance(p); // consume SWAP
     
    -    // First variable
    -    if (!check(p, TOK_IDENT)) {
    -        errorExpected(p, "variable name");
    +    SwapOperandT a;
    +    SwapOperandT b;
    +
    +    // Two scalars: load a, load b, store a, store b -- no temporaries.
    +    if (!swapNeedsTemps(p)) {
    +        if (!parseSwapOperand(p, &a)) {
    +            return;
    +        }
    +        expect(p, TOK_COMMA);
    +        if (!parseSwapOperand(p, &b)) {
    +            return;
    +        }
    +        emitStore(p, a.sym);
    +        emitStore(p, b.sym);
             return;
         }
     
    -    char nameA[BAS_MAX_TOKEN_LEN];
    -    strncpy(nameA, p->lex.token.text, BAS_MAX_TOKEN_LEN - 1);
    -    nameA[BAS_MAX_TOKEN_LEN - 1] = '\0';
    -    advance(p);
    -
    -    BasSymbolT *symA = ensureVariable(p, nameA);
    -    if (symA == NULL) {
    +    // Array elements: park both values in temporaries, then store each
    +    // back into the other operand.
    +    if (!parseSwapOperand(p, &a)) {
             return;
         }
    +    BasSymbolT *tmpA = newTempVar(p);
    +    if (tmpA == NULL) {
    +        return;
    +    }
    +    emitStore(p, tmpA);
     
         expect(p, TOK_COMMA);
    +    if (!parseSwapOperand(p, &b)) {
    +        return;
    +    }
    +    BasSymbolT *tmpB = newTempVar(p);
    +    if (tmpB == NULL) {
    +        return;
    +    }
    +    emitStore(p, tmpB);
     
    -    // Second variable
    +    BasLexerT endLex = p->lex;
    +    emitSwapStore(p, &a, tmpB);
    +    emitSwapStore(p, &b, tmpA);
    +    p->lex = endLex;
    +}
    +
    +
    +// One SWAP operand.  Emits the load of its current value and records what
    +// is needed to store back into it.
    +static bool parseSwapOperand(BasParserT *p, SwapOperandT *op) {
         if (!check(p, TOK_IDENT)) {
             errorExpected(p, "variable name");
    -        return;
    +        return false;
         }
     
    -    char nameB[BAS_MAX_TOKEN_LEN];
    -    strncpy(nameB, p->lex.token.text, BAS_MAX_TOKEN_LEN - 1);
    -    nameB[BAS_MAX_TOKEN_LEN - 1] = '\0';
    +    char name[BAS_MAX_TOKEN_LEN];
    +    strncpy(name, p->lex.token.text, BAS_MAX_TOKEN_LEN - 1);
    +    name[BAS_MAX_TOKEN_LEN - 1] = '\0';
         advance(p);
     
    -    BasSymbolT *symB = ensureVariable(p, nameB);
    -    if (symB == NULL) {
    -        return;
    +    memset(op, 0, sizeof(*op));
    +    op->sym = ensureVariable(p, name);
    +    if (op->sym == NULL) {
    +        return false;
         }
     
    -    // Emit: load a, load b, store a, store b
    -    emitLoad(p, symA);
    -    emitLoad(p, symB);
    -    emitStore(p, symA);
    -    emitStore(p, symB);
    +    if (check(p, TOK_LPAREN)) {
    +        if (!op->sym->isArray) {
    +            char buf[BAS_PARSE_ERR_SCRATCH];
    +            snprintf(buf, sizeof(buf), "'%s' is not an array", name);
    +            error(p, buf);
    +            return false;
    +        }
    +        advance(p); // consume (
    +        op->isElement = true;
    +        op->indexLex  = p->lex;
    +        emitLoad(p, op->sym);
    +        op->dims = parseIndexList(p);
    +        basEmit8(&p->cg, OP_LOAD_ARRAY);
    +        basEmit8(&p->cg, (uint8_t)op->dims);
    +        return !p->hasError;
    +    }
    +
    +    emitLoad(p, op->sym);
    +    return !p->hasError;
     }
     
     
    @@ -6420,12 +6884,15 @@ static void parseType(BasParserT *p) {
     
             expect(p, TOK_AS);
             field.dataType = resolveTypeName(p);
    +        if (field.dataType == BAS_TYPE_STRING) {
    +            field.fixedLen = parseFixedStringLen(p);
    +        }
             if (field.dataType == BAS_TYPE_UDT) {
                 field.udtTypeId = p->lastUdtTypeId;
     
                 // The TYPE symbol is registered before its fields so nested
                 // types resolve; a field of the type being defined would make
    -            // emitUdtInit recurse without end.
    +            // the layout builder recurse without end.
                 if (field.udtTypeId == typeSym->index) {
                     char buf[BAS_PARSE_ERR_SCRATCH];
                     snprintf(buf, sizeof(buf), "TYPE '%s' cannot contain a field of its own type", typeSym->name);
    @@ -6988,3 +7455,32 @@ static uint8_t suffixToType(const char *name) {
     }
     
     
    +// Look ahead over "a, b" without emitting code: true when either SWAP
    +// operand is an array element (subscripts follow the name).
    +static bool swapNeedsTemps(BasParserT *p) {
    +    BasLexerT savedLex = p->lex;
    +    bool      needs    = false;
    +    int32_t   depth    = 0;
    +
    +    while (!check(p, TOK_NEWLINE) && !check(p, TOK_EOF) && !check(p, TOK_COLON) && !check(p, TOK_ELSE) && !p->hasError) {
    +        if (check(p, TOK_IDENT)) {
    +            advance(p);
    +            if (depth == 0 && check(p, TOK_LPAREN)) {
    +                needs = true;
    +                break;
    +            }
    +            continue;
    +        }
    +        if (check(p, TOK_LPAREN)) {
    +            depth++;
    +        } else if (check(p, TOK_RPAREN)) {
    +            depth--;
    +        }
    +        advance(p);
    +    }
    +
    +    p->lex = savedLex;
    +    return needs;
    +}
    +
    +
    diff --git a/src/apps/kpunch/dvxbasic/compiler/parser.h b/src/apps/kpunch/dvxbasic/compiler/parser.h
    index 350d262..a795aae 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/parser.h
    +++ b/src/apps/kpunch/dvxbasic/compiler/parser.h
    @@ -97,6 +97,7 @@ typedef struct {
         int32_t      errorLine;
         int32_t      prevLine;      // line of the previous token (for error reporting)
         int32_t      lastUdtTypeId;  // index of last resolved UDT type from resolveTypeName
    +    int32_t      tempCount;      // compiler temporaries allocated so far (SWAP of array elements)
         int32_t      optionBase;         // default array lower bound (0 or 1)
         bool         optionExplicit;     // true = variables must be declared with DIM
         bool         currentProcIsFunction; // true while parsing a FUNCTION body (EXIT FUNCTION / return-value assignment)
    diff --git a/src/apps/kpunch/dvxbasic/compiler/symtab.h b/src/apps/kpunch/dvxbasic/compiler/symtab.h
    index 28b778d..65f48f2 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/symtab.h
    +++ b/src/apps/kpunch/dvxbasic/compiler/symtab.h
    @@ -73,6 +73,7 @@ typedef struct {
         char    name[BAS_MAX_IDENT];
         uint8_t dataType;  // BAS_TYPE_*
         int32_t udtTypeId; // if dataType == BAS_TYPE_UDT, index of the TYPE_DEF symbol
    +    int32_t fixedLen;  // for STRING * n fields: fixed length (0 = variable-length)
     } BasFieldDefT;
     
     typedef struct {
    @@ -111,6 +112,7 @@ typedef struct {
         uint8_t      dataType;     // BAS_TYPE_* for variables/functions
         bool         isDefined;    // false = forward-declared
         bool         isArray;
    +    bool         isDynamic;    // array declared without bounds (DIM a(), array parameter, or created by REDIM): ERASE frees it
         bool         isShared;
         bool         isExtern;     // true = external library function (DECLARE LIBRARY)
         bool         formScopeEnded; // true = form scope ended, invisible to lookups
    diff --git a/src/apps/kpunch/dvxbasic/formrt/basNativeDos.c b/src/apps/kpunch/dvxbasic/formrt/basNativeDos.c
    new file mode 100644
    index 0000000..1106156
    --- /dev/null
    +++ b/src/apps/kpunch/dvxbasic/formrt/basNativeDos.c
    @@ -0,0 +1,90 @@
    +// 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.
    +
    +// basNativeDos.c -- DECLARE LIBRARY native call trampoline (i386 / DJGPP)
    +//
    +// Pushes the marshalled 32-bit argument words right-to-left, calls the
    +// resolved symbol with the cdecl convention, and returns EAX (or the x87
    +// ST(0) result for floating-point returns).  The host build links
    +// basNativeHost.c instead; the two are selected by the Makefile object
    +// list, never by preprocessor conditionals.
    +
    +#include "formrt.h"
    +
    +
    +// 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 (an in/out operand, pre-loaded with the
    +// count) and EBX the target -- fixed registers, because with EAX, ECX,
    +// EDX, ESI and EDI all spoken for there is nothing left to allocate;
    +// the rest 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.
    +uint32_t basNativeCall(void *funcPtr, const uint32_t *nativeArgs, int32_t nativeCount, bool fpReturn, double *fpResult) {
    +    uint32_t rawResult = 0;
    +    int32_t  cnt       = nativeCount;
    +
    +    if (fpReturn) {
    +        __asm__ __volatile__(
    +            "movl %%esp, %%edi\n\t"          // save ESP
    +            "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), [cnt] "+c"(cnt)
    +            : [fn] "b"(funcPtr), "S"(nativeArgs)
    +            : "edi", "edx", "cc", "memory"
    +        );
    +    } else {
    +        __asm__ __volatile__(
    +            "movl %%esp, %%edi\n\t"          // save ESP
    +            "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), [cnt] "+c"(cnt)
    +            : [fn] "b"(funcPtr), "S"(nativeArgs)
    +            : "edi", "edx", "cc", "memory"
    +        );
    +    }
    +
    +    return rawResult;
    +}
    diff --git a/src/apps/kpunch/dvxbasic/formrt/basNativeHost.c b/src/apps/kpunch/dvxbasic/formrt/basNativeHost.c
    new file mode 100644
    index 0000000..ef07246
    --- /dev/null
    +++ b/src/apps/kpunch/dvxbasic/formrt/basNativeHost.c
    @@ -0,0 +1,41 @@
    +// 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.
    +
    +// basNativeHost.c -- DECLARE LIBRARY native call trampoline (host build)
    +//
    +// The i386 cdecl trampoline in basNativeDos.c cannot run on a 64-bit
    +// host and the test harness has no libffi, so extern calls are refused
    +// here: nothing is called and the result reads as zero.  Selected by the
    +// Makefile object list of the host basrt archive.
    +
    +#include "formrt.h"
    +
    +
    +uint32_t basNativeCall(void *funcPtr, const uint32_t *nativeArgs, int32_t nativeCount, bool fpReturn, double *fpResult) {
    +    (void)funcPtr;
    +    (void)nativeArgs;
    +    (void)nativeCount;
    +    (void)fpReturn;
    +
    +    *fpResult = 0.0;
    +    return 0;
    +}
    diff --git a/src/apps/kpunch/dvxbasic/formrt/formrt.c b/src/apps/kpunch/dvxbasic/formrt/formrt.c
    index 2dc3483..b4c94ff 100644
    --- a/src/apps/kpunch/dvxbasic/formrt/formrt.c
    +++ b/src/apps/kpunch/dvxbasic/formrt/formrt.c
    @@ -77,7 +77,7 @@
     #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.
    +// dlsym name buffer: DVX_SYM_PREFIX + the longest BASIC identifier.
     #define BAS_MANGLED_NAME_LEN (BAS_MAX_IDENT + 2)
     
     // Comma-separated property-name list for "Valid: ..." diagnostics.
    @@ -238,10 +238,21 @@ typedef struct {
     } BasFrmMenuItemT;
     
     
    +// ContextMenu assignment held back until the parse ends: the popup it
    +// names is built from the accumulated menu items only after every
    +// control exists, so applying it inline would find no menu and bind
    +// nothing.  ctrl == NULL is the form-level property.
     typedef struct {
    -    BasFormRtT       *rt;
    -    BasFormT         *form;
    -    BasControlT      *current;
    +    BasControlT *ctrl;
    +    char         menuName[BAS_MAX_IDENT];
    +} BasFrmPendingMenuT;
    +
    +
    +typedef struct {
    +    BasFormRtT         *rt;
    +    BasFormT           *form;
    +    BasControlT        *current;
    +    BasFrmPendingMenuT *pendingMenus;   // stb_ds array
         WidgetT          *parentStack[FRM_MAX_NESTING];
         int32_t           nestDepth;
         bool              containerStack[FRM_MAX_NESTING];
    @@ -415,12 +426,12 @@ void basFormRtSetEvent(void *ctx, void *ctrlRef, const char *eventName, const ch
     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 basFormRtTestReset(void);
     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);
    @@ -470,6 +481,7 @@ 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 void frmLoadQueueContextMenu(BasFrmLoadCtxT *ctx, BasControlT *ctrl, const char *menuName);
     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);
    @@ -510,6 +522,7 @@ 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 BasControlT *resolveCtrlRef(BasFormRtT *rt, void *ref);
     static MenuItemT *resolveMenuItem(BasFormT *form, int32_t menuId, bool *fromBar);
     static BasFormT *resolveOwningForm(BasFormRtT *rt, const BasProcEntryT *proc);
     static bool resolveShellIdle(void);
    @@ -734,7 +747,7 @@ void *basExternResolve(void *ctx, const char *libName, const char *funcName) {
         (void)libName;
     
         char mangledName[BAS_MANGLED_NAME_LEN];
    -    snprintf(mangledName, sizeof(mangledName), "_%s", funcName);
    +    snprintf(mangledName, sizeof(mangledName), DVX_SYM_PREFIX "%s", funcName);
     
         return dlsym(NULL, mangledName);
     }
    @@ -815,8 +828,8 @@ void basFormRtBindVm(BasFormRtT *rt) {
     
     
     BasValueT basFormRtCallMethod(void *ctx, void *ctrlRef, const char *methodName, BasValueT *args, int32_t argc) {
    -    BasFormRtT *rt = (BasFormRtT *)ctx;
    -    BasControlT *ctrl = (BasControlT *)ctrlRef;
    +    BasFormRtT  *rt   = (BasFormRtT *)ctx;
    +    BasControlT *ctrl = resolveCtrlRef(rt, ctrlRef);
     
         if (!ctrl) {
             basFormRtRuntimeError(rt,
    @@ -1337,16 +1350,15 @@ void *basFormRtFindCtrl(void *ctx, void *formRef, const char *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);
    +    // Module-level code runs with no current form at all (NULL); it can
    +    // still name any loaded form or control, so the scan covers every
    +    // form in that case.
    +    BasControlT *hit = form ? findNamedObjectInForm(form, ctrlName) : NULL;
     
         if (hit || !rt) {
             return hit;
    @@ -1379,13 +1391,19 @@ void *basFormRtFindCtrlIdx(void *ctx, void *formRef, const char *ctrlName, int32
                      "%s(%d)", ctrlName, (int)index);
         }
     
    -    if (!form) {
    -        return NULL;
    -    }
    +    // Same scan order as basFormRtFindCtrl: the current form (when the
    +    // caller has one), then every other loaded form.
    +    for (int32_t f = -1; f < (int32_t)arrlen(rt->forms); f++) {
    +        BasFormT *scan = (f < 0) ? form : rt->forms[f];
     
    -    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];
    +        if (!scan || (f >= 0 && scan == form)) {
    +            continue;
    +        }
    +
    +        for (int32_t i = 0; i < (int32_t)arrlen(scan->controls); i++) {
    +            if (scan->controls[i]->index == index && strcasecmp(scan->controls[i]->name, ctrlName) == 0) {
    +                return scan->controls[i];
    +            }
             }
         }
     
    @@ -1429,8 +1447,8 @@ const BasPropDescT *basFormRtFormProps(int32_t *count) {
     
     
     BasValueT basFormRtGetProp(void *ctx, void *ctrlRef, const char *propName) {
    -    BasFormRtT *rt = (BasFormRtT *)ctx;
    -    BasControlT *ctrl = (BasControlT *)ctrlRef;
    +    BasFormRtT  *rt   = (BasFormRtT *)ctx;
    +    BasControlT *ctrl = resolveCtrlRef(rt, ctrlRef);
     
         if (!ctrl) {
             basFormRtRuntimeError(rt,
    @@ -1624,6 +1642,17 @@ void *basFormRtLoadForm(void *ctx, const char *formName) {
             }
         }
     
    +    // A form whose Unload is committed but deferred (a handler is still
    +    // on the stack) keeps answering to its name until the frees flush:
    +    // 'Unload X' compiles to a Load-then-Unload pair, and re-parsing
    +    // the cached .frm here would resurrect the form and unload it a
    +    // second time.  Unload on the returned form is a no-op (unloading).
    +    for (int32_t i = 0; i < (int32_t)arrlen(rt->pendingUnload); i++) {
    +        if (strcasecmp(rt->pendingUnload[i]->name, formName) == 0) {
    +            return rt->pendingUnload[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
    @@ -1728,6 +1757,7 @@ BasFormT *basFormRtLoadFrm(BasFormRtT *rt, const char *source, int32_t sourceLen
             }
     
             arrfree(ctx.menuItems);
    +        arrfree(ctx.pendingMenus);
             return NULL;
         }
     
    @@ -1844,6 +1874,15 @@ BasFormT *basFormRtLoadFrm(BasFormRtT *rt, const char *source, int32_t sourceLen
             arrfree(menuItems);
             menuItems = NULL;
     
    +        // Bind the ContextMenu assignments now that the popups exist.
    +        for (int32_t i = 0; i < (int32_t)arrlen(ctx.pendingMenus); i++) {
    +            BasFrmPendingMenuT *pending = &ctx.pendingMenus[i];
    +            BasValueT           name    = basValStringFromC(pending->menuName);
    +
    +            basFormRtSetProp(rt, pending->ctrl ? pending->ctrl : &form->formCtrl, "ContextMenu", name);
    +            basValRelease(&name);
    +        }
    +
             // 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
    @@ -1873,6 +1912,8 @@ BasFormT *basFormRtLoadFrm(BasFormRtT *rt, const char *source, int32_t sourceLen
             }
         }
     
    +    arrfree(ctx.pendingMenus);
    +
         // Cache the .frm source for reload after unload
         if (form) {
             bool cached = false;
    @@ -2013,15 +2054,17 @@ void basFormRtRunSimple(BasFormRtT *rt) {
                 if (!dvxUpdate(rt->ctx)) {
                     break;
                 }
    -        } else if (result == BAS_VM_ERROR) {
    +        } else if (result != BAS_VM_HALTED && result != BAS_VM_OK) {
                 // 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.
    +            // box for VM-internal errors.  Every non-HALTED/OK result is
    +            // an error here: BAS_VM_ERROR carries a message, and the
    +            // specific codes (division by zero, type mismatch, bad
    +            // opcode, ...) must not end the program silently.
                 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");
    +                snprintf(buf, sizeof(buf), "Runtime error:\n%s", errMsg[0] ? errMsg : "Unknown error");
                     dvxMessageBox(rt->ctx, "Error", buf, 0);
                 }
                 break;
    @@ -2188,8 +2231,7 @@ void basFormRtSerialShutdown(void) {
     
     
     void basFormRtSetEvent(void *ctx, void *ctrlRef, const char *eventName, const char *handlerName) {
    -    (void)ctx;
    -    BasControlT *ctrl = (BasControlT *)ctrlRef;
    +    BasControlT *ctrl = resolveCtrlRef((BasFormRtT *)ctx, ctrlRef);
     
         if (!ctrl || !eventName || !handlerName) {
             return;
    @@ -2213,8 +2255,8 @@ void basFormRtSetEvent(void *ctx, void *ctrlRef, const char *eventName, const ch
     
     
     void basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT value) {
    -    BasFormRtT *rt = (BasFormRtT *)ctx;
    -    BasControlT *ctrl = (BasControlT *)ctrlRef;
    +    BasFormRtT  *rt   = (BasFormRtT *)ctx;
    +    BasControlT *ctrl = resolveCtrlRef(rt, ctrlRef);
     
         if (!ctrl) {
             basFormRtRuntimeError(rt,
    @@ -2328,9 +2370,18 @@ void basFormRtShowForm(void *ctx, void *formRef, bool modal) {
         // re-arming the input gate on a window the unload just hid.
         rtEventEnter(rt);
     
    +    // The window manager fires onFocus (Activate) only on a focus
    +    // CHANGE.  A form created hidden is already the focused window, so
    +    // its first Show would never activate; fire it here in that case.
    +    bool activateHere = !form->window->visible && form->window->focused;
    +
         dvxShowWindow(rt->ctx, form->window);
         dvxRaiseWindow(rt->ctx, form->window);
     
    +    if (activateHere && !form->unloading && form->window->focused) {
    +        basFormRtFireEvent(rt, form, form->name, BAS_EVT_ACTIVATE);
    +    }
    +
         if (!form->unloading) {
             if (form->frmAutoSize) {
                 dvxFitWindow(rt->ctx, form->window);
    @@ -2405,6 +2456,39 @@ static void basFormRtTeardownForm(BasFormRtT *rt, BasFormT *form) {
     }
     
     
    +// Test-harness hook: return every module-level static to its load-time
    +// state so the next basFormRtCreate in the same process starts clean.
    +// Serial/comm state goes through the normal shutdown path first so open
    +// ports and idle pollers are released rather than leaked.
    +void basFormRtTestReset(void) {
    +    basFormRtSerialShutdown();
    +
    +    for (int32_t i = 0; i < RES_MAX_HANDLES; i++) {
    +        if (sResHandles[i]) {
    +            dvxResClose(sResHandles[i]);
    +            sResHandles[i] = NULL;
    +        }
    +    }
    +
    +    memset(sSerAttach, 0, sizeof(sSerAttach));
    +    memset(&sSerApi, 0, sizeof(sSerApi));
    +    memset(&sCommApi, 0, sizeof(sCommApi));
    +
    +    sSerApiResolved        = false;
    +    sCommApiResolved       = false;
    +    sHlpcCompile           = NULL;
    +    sHlpcResolved          = false;
    +    sShellLoadApp          = NULL;
    +    sShellLoadResolved     = false;
    +    sShellRegisterIdle     = NULL;
    +    sShellUnregisterIdle   = NULL;
    +    sShellIdleResolved     = false;
    +    sFormRt                = NULL;
    +    sLoadingFrm            = false;
    +    sLastInputBoxCancelled = false;
    +}
    +
    +
     void basFormRtUnloadForm(void *ctx, void *formRef) {
         BasFormRtT *rt   = (BasFormRtT *)ctx;
         BasFormT   *form = (BasFormT *)formRef;
    @@ -2493,63 +2577,6 @@ static const BasProcEntryT *basModuleFindProc(const BasModuleT *mod, const char
     }
     
     
    -// 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;
    @@ -3185,14 +3212,14 @@ static bool commResolveApi(void) {
         }
     
         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");
    +    sCommApi.open       = dlsym(NULL, DVX_SYM("secLinkOpen"));
    +    sCommApi.close      = dlsym(NULL, DVX_SYM("secLinkClose"));
    +    sCommApi.handshake  = dlsym(NULL, DVX_SYM("secLinkHandshake"));
    +    sCommApi.isReady    = dlsym(NULL, DVX_SYM("secLinkIsReady"));
    +    sCommApi.poll       = dlsym(NULL, DVX_SYM("secLinkPoll"));
    +    sCommApi.send       = dlsym(NULL, DVX_SYM("secLinkSend"));
    +    sCommApi.sendBuf    = dlsym(NULL, DVX_SYM("secLinkSendBuf"));
    +    sCommApi.getPending = dlsym(NULL, DVX_SYM("secLinkGetPending"));
     
         return sCommApi.open != NULL;
     }
    @@ -3952,6 +3979,11 @@ static void frmLoadOnCtrlProp(void *userData, const char *key, const char *value
         snprintf(scratch, sizeof(scratch), "%s", value);
         frmStripQuotes(scratch);
     
    +    if (strcasecmp(key, "ContextMenu") == 0) {
    +        frmLoadQueueContextMenu(ctx, ctrl, scratch);
    +        return;
    +    }
    +
         // Layout property on a container: replace the parentStack entry
         // with a layout box inside the container widget.  VBox is the
         // default for Frame, so no wrapper needed.
    @@ -4084,12 +4116,14 @@ static void frmLoadOnFormProp(void *userData, const char *key, const char *value
                 snprintf(frm->helpTopic, sizeof(frm->helpTopic), "%s", text);
                 break;
     
    +        case FORM_PROP_CONTEXTMENU:
    +            frmLoadQueueContextMenu(ctx, NULL, 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).
    +            // Name comes from the Begin line; Visible is runtime-only.
                 break;
         }
     }
    @@ -4152,6 +4186,18 @@ static void frmLoadOnMenuProp(void *userData, const char *key, const char *value
     }
     
     
    +// Remember a ContextMenu assignment for basFormRtLoadFrm to apply once
    +// the popup menus exist (see BasFrmPendingMenuT).
    +static void frmLoadQueueContextMenu(BasFrmLoadCtxT *ctx, BasControlT *ctrl, const char *menuName) {
    +    BasFrmPendingMenuT pending;
    +
    +    memset(&pending, 0, sizeof(pending));
    +    pending.ctrl = ctrl;
    +    snprintf(pending.menuName, sizeof(pending.menuName), "%s", menuName);
    +    arrput(ctx->pendingMenus, pending);
    +}
    +
    +
     static BasValueT getCommonProp(BasControlT *ctrl, const char *propName, bool *handled) {
         const BasPropDescT *pd = basFormRtFindCommonProp(propName);
         WidgetT            *w  = ctrl->widget;
    @@ -4355,7 +4401,7 @@ static BasValueT getIfaceProp(const WgtIfaceT *iface, WidgetT *w, const char *pr
     int32_t HelpCompile(const char *inputFile, const char *outputFile) {
         if (!sHlpcResolved) {
             sHlpcResolved = true;
    -        sHlpcCompile  = dlsym(NULL, "_hlpcCompile");
    +        sHlpcCompile  = dlsym(NULL, DVX_SYM("hlpcCompile"));
         }
     
         if (!sHlpcCompile || !inputFile || !outputFile) {
    @@ -4373,7 +4419,7 @@ int32_t HelpCompile(const char *inputFile, const char *outputFile) {
     void HelpView(const char *hlpFile) {
         if (!sShellLoadResolved) {
             sShellLoadResolved = true;
    -        sShellLoadApp      = dlsym(NULL, "_shellLoadAppWithArgs");
    +        sShellLoadApp      = dlsym(NULL, DVX_SYM("shellLoadAppWithArgs"));
         }
     
         if (!sShellLoadApp || !sFormRt || !sFormRt->ctx || !hlpFile) {
    @@ -5100,6 +5146,32 @@ const char *ResName(int32_t handle, int32_t index) {
     }
     
     
    +// Object references reach the property/method bridges as either a
    +// BasControlT* (FindCtrl) or a BasFormT* (Me, CreateForm, LoadForm).
    +// Map a form reference onto its synthetic form control so both kinds
    +// take the same path.  Detached (pending-unload) forms are included:
    +// a handler may still touch Me after Unload Me.
    +static BasControlT *resolveCtrlRef(BasFormRtT *rt, void *ref) {
    +    if (!rt || !ref) {
    +        return (BasControlT *)ref;
    +    }
    +
    +    for (int32_t i = 0; i < (int32_t)arrlen(rt->forms); i++) {
    +        if (rt->forms[i] == ref) {
    +            return &rt->forms[i]->formCtrl;
    +        }
    +    }
    +
    +    for (int32_t i = 0; i < (int32_t)arrlen(rt->pendingUnload); i++) {
    +        if (rt->pendingUnload[i] == ref) {
    +            return &rt->pendingUnload[i]->formCtrl;
    +        }
    +    }
    +
    +    return (BasControlT *)ref;
    +}
    +
    +
     // 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
    @@ -5170,8 +5242,8 @@ static BasFormT *resolveOwningForm(BasFormRtT *rt, const BasProcEntryT *proc) {
     static bool resolveShellIdle(void) {
         if (!sShellIdleResolved) {
             sShellIdleResolved   = true;
    -        sShellRegisterIdle   = dlsym(NULL, "_shellRegisterIdle");
    -        sShellUnregisterIdle = dlsym(NULL, "_shellUnregisterIdle");
    +        sShellRegisterIdle   = dlsym(NULL, DVX_SYM("shellRegisterIdle"));
    +        sShellUnregisterIdle = dlsym(NULL, DVX_SYM("shellUnregisterIdle"));
         }
     
         return sShellRegisterIdle != NULL;
    @@ -5514,18 +5586,18 @@ static bool serResolveApi(void) {
         }
     
         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");
    +    sSerApi.open          = dlsym(NULL, DVX_SYM("rs232Open"));
    +    sSerApi.close         = dlsym(NULL, DVX_SYM("rs232Close"));
    +    sSerApi.read          = dlsym(NULL, DVX_SYM("rs232Read"));
    +    sSerApi.write         = dlsym(NULL, DVX_SYM("rs232Write"));
    +    sSerApi.writeBuf      = dlsym(NULL, DVX_SYM("rs232WriteBuf"));
    +    sSerApi.getRxBuffered = dlsym(NULL, DVX_SYM("rs232GetRxBuffered"));
    +    sSerApi.clearRxBuffer = dlsym(NULL, DVX_SYM("rs232ClearRxBuffer"));
    +    sSerApi.getUartType   = dlsym(NULL, DVX_SYM("rs232GetUartType"));
    +    sSerApi.getBase       = dlsym(NULL, DVX_SYM("rs232GetBase"));
    +    sSerApi.getIrq        = dlsym(NULL, DVX_SYM("rs232GetIrq"));
    +    sSerApi.setBase       = dlsym(NULL, DVX_SYM("rs232SetBase"));
    +    sSerApi.setIrq        = dlsym(NULL, DVX_SYM("rs232SetIrq"));
     
         return sSerApi.open != NULL;
     }
    diff --git a/src/apps/kpunch/dvxbasic/formrt/formrt.h b/src/apps/kpunch/dvxbasic/formrt/formrt.h
    index 6033ec8..7bd1a39 100644
    --- a/src/apps/kpunch/dvxbasic/formrt/formrt.h
    +++ b/src/apps/kpunch/dvxbasic/formrt/formrt.h
    @@ -82,8 +82,8 @@ const BasPropDescT *basFormRtFormProps(int32_t *count);
     const BasPropDescT *basFormRtCommonProps(int32_t *count);
     
     // Case-insensitive lookups; NULL when the name is not in the table.
    -const BasPropDescT *basFormRtFindFormProp(const char *name);
     const BasPropDescT *basFormRtFindCommonProp(const char *name);
    +const BasPropDescT *basFormRtFindFormProp(const char *name);
     
     // ============================================================
     // Common methods
    @@ -302,27 +302,41 @@ void basFormRtDestroy(BasFormRtT *rt);
     // Called by basFormRtDestroy and by the stub's _appShutdown force-kill hook.
     void basFormRtSerialShutdown(void);
     
    +// Test-only: clear every file-scope static so a host test process can run
    +// many create/destroy cycles.  Not used by the DOS build.
    +void basFormRtTestReset(void);
    +
     // Wire up the VM's UI callbacks to this form runtime.
     void basFormRtBindVm(BasFormRtT *rt);
     
    +// Report a non-recoverable runtime error: log, halt the VM, mark the
    +// runtime terminated and (unless suppressErrorDialog) show the modal
    +// error box.  Follow-on errors while terminated are swallowed.
    +void basFormRtRuntimeError(BasFormRtT *rt, const char *summary, const char *detailFmt, ...);
    +
     // ---- UI callback implementations (match BasUiCallbacksT) ----
     
    -BasValueT basFormRtGetProp(void *ctx, void *ctrlRef, const char *propName);
    -void      basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT value);
     BasValueT basFormRtCallMethod(void *ctx, void *ctrlRef, const char *methodName, BasValueT *args, int32_t argc);
     void     *basFormRtCreateCtrl(void *ctx, void *formRef, const char *typeName, const char *ctrlName);
     void     *basFormRtFindCtrl(void *ctx, void *formRef, const char *ctrlName);
     void     *basFormRtFindCtrlIdx(void *ctx, void *formRef, const char *ctrlName, int32_t index);
    -void     *basFormRtLoadForm(void *ctx, const char *formName);
    -void      basFormRtUnloadForm(void *ctx, void *formRef);
    -void      basFormRtShowForm(void *ctx, void *formRef, bool modal);
    +BasValueT basFormRtGetProp(void *ctx, void *ctrlRef, const char *propName);
     void      basFormRtHideForm(void *ctx, void *formRef);
    +void     *basFormRtLoadForm(void *ctx, const char *formName);
     int32_t   basFormRtMsgBox(void *ctx, const char *message, int32_t flags, const char *title);
    +void      basFormRtSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT value);
    +void      basFormRtShowForm(void *ctx, void *formRef, bool modal);
    +void      basFormRtUnloadForm(void *ctx, void *formRef);
     
     // ---- Extern call callbacks (shared by IDE and stub) ----
     
     void *basExternResolve(void *ctx, const char *libName, const char *funcName);
    +
    +// Native cdecl trampoline behind basExternCall.  basNativeDos.c carries
    +// the i386 asm; basNativeHost.c is the 64-bit host stub that calls
    +// nothing.  Selected by the Makefile object list.
     BasValueT basExternCall(void *ctx, void *funcPtr, const char *libName, const char *funcName, BasValueT *args, int32_t argc, uint8_t retType);
    +uint32_t basNativeCall(void *funcPtr, const uint32_t *nativeArgs, int32_t nativeCount, bool fpReturn, double *fpResult);
     
     // ---- Form caching ----
     
    @@ -361,10 +375,10 @@ WindowT  *basFormRtCreateFormWindow(AppContextT *ctx, const char *title, const c
     
     // ---- Dynamic form/control API ----
     
    -void     *basFormRtCreateForm(void *ctx, const char *formName, int32_t width, int32_t height);
     void     *basFormRtCreateCtrlEx(void *ctx, void *formRef, const char *typeName, const char *ctrlName, void *parentRef);
    -void      basFormRtSetEvent(void *ctx, void *ctrlRef, const char *eventName, const char *handlerName);
    +void     *basFormRtCreateForm(void *ctx, const char *formName, int32_t width, int32_t height);
     void      basFormRtRemoveCtrl(void *ctx, void *formRef, const char *ctrlName);
    +void      basFormRtSetEvent(void *ctx, void *ctrlRef, const char *eventName, const char *handlerName);
     
     // ---- Event dispatch ----
     
    diff --git a/src/apps/kpunch/dvxbasic/help.dhs b/src/apps/kpunch/dvxbasic/help.dhs
    new file mode 100644
    index 0000000..df2cb14
    --- /dev/null
    +++ b/src/apps/kpunch/dvxbasic/help.dhs
    @@ -0,0 +1 @@
    +.h1 Help
    diff --git a/src/apps/kpunch/dvxbasic/ide/ideDesigner.h b/src/apps/kpunch/dvxbasic/ide/ideDesigner.h
    index 04364cb..bd5fd28 100644
    --- a/src/apps/kpunch/dvxbasic/ide/ideDesigner.h
    +++ b/src/apps/kpunch/dvxbasic/ide/ideDesigner.h
    @@ -265,8 +265,8 @@ void dsgnSyncWidgetGeom(DsgnControlT *ctrl);
     
     // Built-in integer property table: entry by index (NULL past the end) and
     // lookup by .frm key (name or alias).
    -const DsgnIntPropT *dsgnIntPropAt(int32_t idx);
     const DsgnIntPropT *dsgnFindIntProp(const char *name);
    +const DsgnIntPropT *dsgnIntPropAt(int32_t idx);
     
     // Text value of a runtime form property (basFormRtFormProps) as the designer
     // stores it; false when the designer has no field for it (runtime-only).
    diff --git a/src/apps/kpunch/dvxbasic/ide/ideMain.c b/src/apps/kpunch/dvxbasic/ide/ideMain.c
    index e3d5ab6..dc401e3 100644
    --- a/src/apps/kpunch/dvxbasic/ide/ideMain.c
    +++ b/src/apps/kpunch/dvxbasic/ide/ideMain.c
    @@ -446,7 +446,9 @@ static void onEditorChange(WidgetT *w);
     static void onEvtDropdownChange(WidgetT *w);
     static void onFindClose(WindowT *win);
     static void onFindCloseBtn(WidgetT *w);
    +static void onFindDirDown(WidgetT *w, int32_t button, int32_t x, int32_t y);
     static void onFindNext(WidgetT *w);
    +static void onFindScopeDown(WidgetT *w, int32_t button, int32_t x, int32_t y);
     static void onFormWinClose(WindowT *win);
     static int32_t onFormWinCursorQuery(WindowT *win, int32_t x, int32_t y);
     static void onFormWinKey(WindowT *win, int32_t key, int32_t mod);
    @@ -456,9 +458,11 @@ static void onFormWinPaint(WindowT *win, RectT *dirtyArea);
     static void onFormWinResize(WindowT *win, int32_t newW, int32_t newH);
     static void onGutterClick(WidgetT *w, int32_t lineNum);
     static void onImmediateChange(WidgetT *w);
    +static void onImmWinClose(WindowT *win);
     static void onLocalsClose(WindowT *win);
     static void onMenu(WindowT *win, int32_t menuId);
     static void onObjDropdownChange(WidgetT *w);
    +static void onOutWinClose(WindowT *win);
     static void onPrefsCancel(WidgetT *w);
     static void onPrefsOk(WidgetT *w);
     static void onPrjFileDblClick(int32_t fileIdx, bool isForm);
    @@ -830,6 +834,10 @@ static WidgetT *sBtnReplAll   = NULL;
     static WidgetT *sCaseCheck    = NULL;
     static WidgetT *sScopeGroup   = NULL;  // radio group: 0=Func, 1=Obj, 2=File, 3=Proj
     static WidgetT *sDirGroup     = NULL;  // radio group: 0=Fwd, 1=Back
    +// The radio-group widget exposes no selection getter, so the dialog
    +// tracks the picks itself through each radio's onMouseDown.
    +static int32_t  sFindScopeIdx = ScopeProjE;
    +static int32_t  sFindDirIdx   = 0;
     
     static IdeProcEntryT *sProcTable    = NULL; // stb_ds dynamic array (rebuilt by joinProcBufs)
     static const char   **sObjItems     = NULL; // stb_ds dynamic array
    @@ -1792,26 +1800,20 @@ static bool compileProject(void) {
         dvxUpdate(sAc);
     
         // Build source: either concatenate project files or use editor contents
    -    char *concatBuf = NULL;
    -    const char *src = NULL;
    -    int32_t srcLen  = 0;
    +    BasProjectSourceT project;
    +    const char       *src    = NULL;
    +    int32_t           srcLen = 0;
    +
    +    memset(&project, 0, sizeof(project));
     
         if (hasProject() && sProject.fileCount > 0) {
             // Stash current editor/designer state into project buffers
             // (for a form this also writes the editor back to form->code)
             stashCurrentFile();
     
    -        // Concatenate all files (each normalized to the canonical proc layout)
    -        concatBuf = (char *)malloc(IDE_MAX_SOURCE);
    -
    -        if (!concatBuf) {
    -            setStatus("Out of memory.");
    -            dvxSetBusy(sAc, false);
    -            return false;
    -        }
    -
    -        int32_t pos  = 0;
    -        int32_t line = 1;
    +        // Concatenate all files (each normalized to the canonical proc
    +        // layout) with the same BEGINFORM/ENDFORM wrapping bascomp and
    +        // basrun use, recording where each file lands for the source map.
             arrfree(sProject.sourceMap);
             sProject.sourceMap      = NULL;
             sProject.sourceMapCount = 0;
    @@ -1830,70 +1832,46 @@ static bool compileProject(void) {
                     if (!fileSrc) {
                         sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, 0, "COMPILE ERROR:\nCannot read %s (missing or larger than %d bytes).\n", sProject.files[i].path, (int)IDE_MAX_SOURCE);
                         showCompileError("Compilation failed: cannot read file.");
    -                    free(concatBuf);
    +                    basBuildFreeProject(&project);
                         return false;
                     }
     
                     int32_t fileLen = (int32_t)strlen(fileSrc);
     
                     // BEGINFORM/ENDFORM directives plus a trailing newline
    -                if (pos + fileLen + IDE_SOURCE_MARGIN >= IDE_MAX_SOURCE) {
    +                if (project.sourceLen + fileLen + IDE_SOURCE_MARGIN >= IDE_MAX_SOURCE) {
                         sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, 0, "COMPILE ERROR:\nProject source exceeds %d bytes at %s.\n", (int)IDE_MAX_SOURCE, sProject.files[i].path);
                         showCompileError("Compilation failed: source too large.");
                         free(fileSrc);
    -                    free(concatBuf);
    +                    basBuildFreeProject(&project);
                         return false;
                     }
     
    -                // Inject BEGINFORM directive for .frm code sections
    -                if (sProject.files[i].isForm && sProject.files[i].formName[0]) {
    -                    pos = emitClamped(concatBuf, IDE_MAX_SOURCE, pos, "BEGINFORM \"%s\"\n", sProject.files[i].formName);
    -                    line++;
    -                }
    -
    -                // Record startLine AFTER injected directives so the source
    -                // map lines match what the editor shows (not the synthetic lines).
    -                int32_t startLine = line;
    -
    -                memcpy(concatBuf + pos, fileSrc, fileLen);
    -                pos += fileLen;
    -
    -                // Count lines
    -                for (int32_t j = 0; j < fileLen; j++) {
    -                    if (fileSrc[j] == '\n') {
    -                        line++;
    -                    }
    -                }
    +                // startLine is recorded AFTER the injected BEGINFORM so the
    +                // source map lines match what the editor shows.
    +                const char   *formName = sProject.files[i].isForm ? sProject.files[i].formName : NULL;
    +                PrjSourceMapT mapEntry;
    +                bool          ok       = basBuildAppendFile(&project, fileSrc, fileLen, formName, &mapEntry.startLine, &mapEntry.lineCount);
     
                     free(fileSrc);
     
    -                // Ensure a trailing newline between files
    -                if (fileLen > 0 && concatBuf[pos - 1] != '\n') {
    -                    concatBuf[pos++] = '\n';
    -                    line++;
    +                if (!ok) {
    +                    setStatus(project.error);
    +                    dvxSetBusy(sAc, false);
    +                    basBuildFreeProject(&project);
    +                    return false;
                     }
     
    -                // Record source map BEFORE injected ENDFORM directive
    -                PrjSourceMapT mapEntry;
    -                mapEntry.fileIdx   = i;
    -                mapEntry.startLine = startLine;
    -                mapEntry.lineCount = line - startLine;
    +                mapEntry.fileIdx = i;
                     arrput(sProject.sourceMap, mapEntry);
                     sProject.sourceMapCount = (int32_t)arrlen(sProject.sourceMap);
     
    -                // Inject ENDFORM directive
    -                if (sProject.files[i].isForm && sProject.files[i].formName[0]) {
    -                    pos = emitClamped(concatBuf, IDE_MAX_SOURCE, pos, "ENDFORM\n");
    -                    line++;
    -                }
    -
                     dvxUpdate(sAc);
                 }
             }
     
    -        concatBuf[pos] = '\0';
    -        src    = concatBuf;
    -        srcLen = pos;
    +        src    = project.source;
    +        srcLen = project.sourceLen;
         } else {
             // No project files -- compile the full source
             src = getFullSource();
    @@ -1911,7 +1889,7 @@ static bool compileProject(void) {
         BasParserT *parser = (BasParserT *)malloc(sizeof(BasParserT));
     
         if (!parser) {
    -        free(concatBuf);
    +        basBuildFreeProject(&project);
             setStatus("Out of memory.");
             dvxSetBusy(sAc, false);
             return false;
    @@ -1948,7 +1926,7 @@ static bool compileProject(void) {
             showCompileError("Compilation failed: unknown widget type.");
             basParserFree(parser);
             free(parser);
    -        free(concatBuf);
    +        basBuildFreeProject(&project);
             arrfree(validatorCtx.entries);
             arrfree(validatorCtx.badTypes);
             return false;
    @@ -2023,13 +2001,13 @@ static bool compileProject(void) {
             showCompileError("Compilation failed.");
             basParserFree(parser);
             free(parser);
    -        free(concatBuf);
    +        basBuildFreeProject(&project);
             arrfree(validatorCtx.entries);
             arrfree(validatorCtx.badTypes);
             return false;
         }
     
    -    free(concatBuf);
    +    basBuildFreeProject(&project);
         arrfree(validatorCtx.entries);
         arrfree(validatorCtx.badTypes);
     
    @@ -3327,7 +3305,7 @@ static bool getFindForward(void) {
             return true;
         }
     
    -    return wgtRadioGetIndex(sDirGroup) == 0;
    +    return sFindDirIdx == 0;
     }
     
     
    @@ -3341,9 +3319,7 @@ static FindScopeE getFindScope(void) {
             return ScopeProjE;
         }
     
    -    int32_t idx = wgtRadioGetIndex(sScopeGroup);
    -
    -    switch (idx) {
    +    switch (sFindScopeIdx) {
             case 0:
                 return ScopeFuncE;
     
    @@ -4045,10 +4021,15 @@ void ideRenameInCode(const char *oldName, const char *newName) {
             }
         }
     
    -    // Update form->code from the renamed buffers (only if editor has this form's code)
    -    if (sDesigner.form && sEditorFileIdx >= 0 && sEditorFileIdx < sProject.fileCount &&
    -        sProject.files[sEditorFileIdx].isForm &&
    -        strcasecmp(sProject.files[sEditorFileIdx].formName, sDesigner.form->name) == 0) {
    +    // Update form->code: from the renamed buffers when the editor holds
    +    // this form's code, otherwise directly -- with the code window closed
    +    // the proc buffers were released, and the designer's copy of the code
    +    // is the only one that exists.
    +    bool editorHasForm = sDesigner.form && sEditorFileIdx >= 0 && sEditorFileIdx < sProject.fileCount &&
    +                         sProject.files[sEditorFileIdx].isForm &&
    +                         strcasecmp(sProject.files[sEditorFileIdx].formName, sDesigner.form->name) == 0;
    +
    +    if (editorHasForm) {
             char *code = strdup(getFullSource());
     
             if (code) {
    @@ -4057,6 +4038,14 @@ void ideRenameInCode(const char *oldName, const char *newName) {
             }
     
             sDesigner.form->dirty = true;
    +    } else if (sDesigner.form && sDesigner.form->code) {
    +        char *replaced = renameInBuffer(sDesigner.form->code, oldName, newName);
    +
    +        if (replaced) {
    +            free(sDesigner.form->code);
    +            sDesigner.form->code  = replaced;
    +            sDesigner.form->dirty = true;
    +        }
         }
     
         // Update cached formName if the active file is a form being renamed
    @@ -4184,9 +4173,9 @@ static void immPrintCallback(void *ctx, const char *text, bool newline) {
     
         const char *cur     = wgtGetText(sImmediate);
         const char *keep    = cur ? cur : "";
    -    int32_t     curLen  = (int32_t)strlen(keep);
    -    int32_t     textLen = text ? (int32_t)strlen(text) : 0;
    -    int32_t     extra   = newline ? 1 : 0;
    +    size_t      curLen  = strlen(keep);
    +    size_t      textLen = text ? strlen(text) : 0;
    +    size_t      extra   = newline ? 1 : 0;
     
         // A single oversize chunk is truncated to what can ever fit
         if (textLen + extra + 1 >= IDE_MAX_IMM) {
    @@ -4203,10 +4192,16 @@ static void immPrintCallback(void *ctx, const char *text, bool newline) {
                 break;
             }
     
    -        curLen -= (int32_t)(nl + 1 - keep);
    +        curLen -= (size_t)(nl + 1 - keep);
             keep    = nl + 1;
         }
     
    +    // Everything above keeps curLen + textLen + extra below IDE_MAX_IMM;
    +    // the clamp makes that visible to the compiler as well.
    +    if (curLen >= IDE_MAX_IMM) {
    +        curLen = IDE_MAX_IMM - 1;
    +    }
    +
         memmove(immBuf, keep, curLen);
     
         if (textLen > 0) {
    @@ -5229,6 +5224,11 @@ static void onClose(WindowT *win) {
         prefsClose(sPrefs);
         sPrefs = NULL;
     
    +    // The accelerator table is shared by every IDE window; all of them
    +    // are gone by now, so release it before the main window goes.
    +    dvxFreeAccelTable(win->accelTable);
    +    win->accelTable = NULL;
    +
         dvxDestroyWindow(sAc, win);
     }
     
    @@ -5418,6 +5418,16 @@ static void onFindCloseBtn(WidgetT *w) {
     }
     
     
    +// Direction radio picked: remember the index (the group widget offers
    +// no way to read it back).
    +static void onFindDirDown(WidgetT *w, int32_t button, int32_t x, int32_t y) {
    +    (void)button;
    +    (void)x;
    +    (void)y;
    +    sFindDirIdx = (int32_t)(intptr_t)w->userData;
    +}
    +
    +
     static void onFindNext(WidgetT *w) {
         (void)w;
     
    @@ -5450,6 +5460,15 @@ static void onFindNext(WidgetT *w) {
     }
     
     
    +// Scope radio picked: remember the index.
    +static void onFindScopeDown(WidgetT *w, int32_t button, int32_t x, int32_t y) {
    +    (void)button;
    +    (void)x;
    +    (void)y;
    +    sFindScopeIdx = (int32_t)(intptr_t)w->userData;
    +}
    +
    +
     // onFormWinClose -- shell callback when user clicks X on the form window.
     static void onFormWinClose(WindowT *win) {
         dvxDestroyWindow(sAc, win);
    @@ -5710,6 +5729,13 @@ static void onImmediateChange(WidgetT *w) {
     }
     
     
    +// The IDE keeps sImmWin/sImmediate for its whole lifetime; destroying
    +// the window here would leave them dangling.
    +static void onImmWinClose(WindowT *win) {
    +    dvxHideWindow(sAc, win);
    +}
    +
    +
     static void onLocalsClose(WindowT *win) {
         dvxHideWindow(sAc, win);
     }
    @@ -6009,6 +6035,12 @@ static void onObjDropdownChange(WidgetT *w) {
     }
     
     
    +// Hide (not destroy): sOutWin/sOutput stay bound for PRINT output.
    +static void onOutWinClose(WindowT *win) {
    +    dvxHideWindow(sAc, win);
    +}
    +
    +
     static void onPrefsCancel(WidgetT *w) {
         (void)w;
         sPrefsDlg.done = true;
    @@ -6379,19 +6411,35 @@ static void openFindDialog(bool showReplace) {
         WidgetT *scopeFrame = wgtFrame(optRow, "Scope");
         WidgetT *scopeBox   = wgtVBox(scopeFrame);
         sScopeGroup = wgtRadioGroup(scopeBox);
    -    wgtRadio(sScopeGroup, "Function");
    -    wgtRadio(sScopeGroup, "Object");
    -    wgtRadio(sScopeGroup, "File");
    -    wgtRadio(sScopeGroup, "Project");
    +
    +    static const char *scopeNames[] = { "Function", "Object", "File", "Project" };
    +
    +    for (int32_t i = 0; i < (int32_t)(sizeof(scopeNames) / sizeof(scopeNames[0])); i++) {
    +        WidgetT *scopeRadio = wgtRadio(sScopeGroup, scopeNames[i]);
    +
    +        scopeRadio->userData    = (void *)(intptr_t)i;
    +        scopeRadio->onMouseDown = onFindScopeDown;
    +    }
    +
         wgtRadioGroupSetSelected(sScopeGroup, ScopeProjE);
    +    sFindScopeIdx = ScopeProjE;
     
         // Direction
         WidgetT *dirFrame = wgtFrame(optRow, "Direction");
         WidgetT *dirBox   = wgtVBox(dirFrame);
         sDirGroup = wgtRadioGroup(dirBox);
    -    wgtRadio(sDirGroup, "Forward");
    -    wgtRadio(sDirGroup, "Backward");
    +
    +    static const char *dirNames[] = { "Forward", "Backward" };
    +
    +    for (int32_t i = 0; i < (int32_t)(sizeof(dirNames) / sizeof(dirNames[0])); i++) {
    +        WidgetT *dirRadio = wgtRadio(sDirGroup, dirNames[i]);
    +
    +        dirRadio->userData    = (void *)(intptr_t)i;
    +        dirRadio->onMouseDown = onFindDirDown;
    +    }
    +
         wgtRadioGroupSetSelected(sDirGroup, 0); // Forward
    +    sFindDirIdx = 0;
     
         // Match Case
         WidgetT *caseBox = wgtVBox(optRow);
    @@ -6444,6 +6492,18 @@ static void openProject(void) {
     
     // openProjectPath -- load a .dbp (the previous project is already closed)
     static void openProjectPath(const char *path) {
    +    // prefsLoad treats a missing file as an empty INI, so prjLoad alone
    +    // cannot reject a stale recent entry; probe the file first or a
    +    // deleted .dbp silently "opens" as a phantom empty project.
    +    FILE *probe = fopen(path, "rb");
    +
    +    if (!probe) {
    +        dvxErrorBox(sAc, NULL, "Could not open project file.");
    +        return;
    +    }
    +
    +    fclose(probe);
    +
         if (!prjLoad(&sProject, path)) {
             dvxErrorBox(sAc, NULL, "Could not open project file.");
             return;
    @@ -7339,7 +7399,12 @@ static void runModule(BasModuleT *mod) {
         if (result == BAS_VM_HALTED && (int32_t)arrlen(formRt->forms) > 0) {
             setStatus("Running (event loop)...");
     
    -        while (sWin && sAc->running && (int32_t)arrlen(formRt->forms) > 0 && !sStopRequested && !vm->ended) {
    +        // vm->errorMsg outliving an event dispatch means an unhandled
    +        // runtime error escaped a handler (ON ERROR clears the message
    +        // when it takes over); without this test the program kept
    +        // running after the error and the report below never fired
    +        // until the user pressed Stop.
    +        while (sWin && sAc->running && (int32_t)arrlen(formRt->forms) > 0 && !sStopRequested && !vm->ended && vm->errorMsg[0] == '\0') {
                 dvxUpdate(sAc);
             }
         }
    @@ -8032,6 +8097,7 @@ static void showCompileError(const char *status) {
     
     static void showImmediateWindow(void) {
         if (sImmWin) {
    +        dvxShowWindow(sAc, sImmWin);
             return;
         }
     
    @@ -8041,6 +8107,7 @@ static void showImmediateWindow(void) {
         sImmWin = dvxCreateWindow(sAc, "Immediate", sAc->display.width / 2, outY, sAc->display.width / 2, outH, true);
     
         if (sImmWin) {
    +        sImmWin->onClose     = onImmWinClose;
             sImmWin->onFocus     = onContentFocus;
             sImmWin->onMenu      = onMenu;
             sImmWin->accelTable  = sWin ? sWin->accelTable : NULL;
    @@ -8111,6 +8178,7 @@ static void showLocalsWindow(void) {
     
     static void showOutputWindow(void) {
         if (sOutWin) {
    +        dvxShowWindow(sAc, sOutWin);
             return;
         }
     
    @@ -8120,6 +8188,7 @@ static void showOutputWindow(void) {
         sOutWin = dvxCreateWindow(sAc, "Output", 0, outY, sAc->display.width / 2, outH, true);
     
         if (sOutWin) {
    +        sOutWin->onClose     = onOutWinClose;
             sOutWin->onFocus     = onContentFocus;
             sOutWin->onMenu      = onMenu;
             sOutWin->accelTable  = sWin ? sWin->accelTable : NULL;
    @@ -8370,15 +8439,18 @@ static void showProc(int32_t procIdx) {
         // If a buffer was deleted (empty skeleton discard), adjust the
         // target index since arrdel shifts everything after it.
         if (sCurProcIdx >= -1) {
    -        int32_t deletedIdx = sCurProcIdx;
    +        int32_t deletedIdx  = sCurProcIdx;
    +        bool    listChanged = saveCurProc();
     
    -        if (saveCurProc()) {
    -            // The proc list changed; keep sProcTable in step with it
    -            joinProcBufs();
    +        // Every later procedure's line number depends on the buffer just
    +        // saved (lines added or removed above it shift them), so the proc
    +        // table is rebuilt after any save -- not only when the list
    +        // changed -- or breakpoints toggled in the next proc land on the
    +        // wrong compile line.
    +        joinProcBufs();
     
    -            if (deletedIdx >= 0 && procIdx > deletedIdx) {
    -                procIdx--;
    -            }
    +        if (listChanged && deletedIdx >= 0 && procIdx > deletedIdx) {
    +            procIdx--;
             }
         }
     
    @@ -9459,10 +9531,13 @@ static bool validatorIsMethodValid(void *ctx, const char *wgtType, const char *m
     
         const WgtIfaceT *iface = wgtGetIface(wgtName);
     
    -    if (!iface || !iface->methods) {
    +    if (!iface) {
             return true;
         }
     
    +    // A known type with no interface methods accepts only the common
    +    // set (already checked above); being permissive here let typos
    +    // like btnGo.Boggle compile.
         for (int32_t i = 0; i < iface->methodCount; i++) {
             if (strcasecmp(iface->methods[i].name, methodName) == 0) {
                 return true;
    @@ -9509,10 +9584,12 @@ static bool validatorIsPropValid(void *ctx, const char *wgtType, const char *pro
     
         const WgtIfaceT *iface = wgtGetIface(wgtName);
     
    -    if (!iface || !iface->props) {
    +    if (!iface) {
             return true;
         }
     
    +    // Same reasoning as validatorIsMethodValid: an empty interface
    +    // property table must not turn validation off for the whole type.
         return wgtIfaceFindProp(iface, propName) != NULL;
     }
     
    diff --git a/src/apps/kpunch/dvxbasic/ide/ideProject.h b/src/apps/kpunch/dvxbasic/ide/ideProject.h
    index 9819f1a..98a7b5c 100644
    --- a/src/apps/kpunch/dvxbasic/ide/ideProject.h
    +++ b/src/apps/kpunch/dvxbasic/ide/ideProject.h
    @@ -107,15 +107,15 @@ typedef struct {
     // Project management
     // ============================================================
     
    +int32_t prjAddFile(PrjStateT *prj, const char *relativePath, bool isForm);
    +void    prjClose(PrjStateT *prj);
    +void    prjFullPath(const PrjStateT *prj, int32_t fileIdx, char *outPath, int32_t outSize);
     void    prjInit(PrjStateT *prj);
     bool    prjLoad(PrjStateT *prj, const char *dbpPath);
    -bool    prjSave(const PrjStateT *prj);
    -void    prjNew(PrjStateT *prj, const char *name, const char *directory, PrefsHandleT *prefs);
    -void    prjClose(PrjStateT *prj);
    -int32_t prjAddFile(PrjStateT *prj, const char *relativePath, bool isForm);
     void    prjLoadAllFiles(PrjStateT *prj, AppContextT *ctx);
    +void    prjNew(PrjStateT *prj, const char *name, const char *directory, PrefsHandleT *prefs);
     void    prjRemoveFile(PrjStateT *prj, int32_t idx);
    -void    prjFullPath(const PrjStateT *prj, int32_t fileIdx, char *outPath, int32_t outSize);
    +bool    prjSave(const PrjStateT *prj);
     
     // ============================================================
     // Source map -- translate concatenated line to file + local line
    @@ -134,8 +134,8 @@ typedef void (*PrjSelChangeFnT)(void);
     
     WindowT *prjCreateWindow(AppContextT *ctx, PrjStateT *prj, PrjFileClickFnT onClick, PrjSelChangeFnT onSelChange);
     void     prjDestroyWindow(AppContextT *ctx, WindowT *win);
    -void     prjRebuildTree(PrjStateT *prj);
     int32_t  prjGetSelectedFileIdx(void);
    +void     prjRebuildTree(PrjStateT *prj);
     
     // ============================================================
     // Project properties dialog
    diff --git a/src/apps/kpunch/dvxbasic/ide/ideProperties.c b/src/apps/kpunch/dvxbasic/ide/ideProperties.c
    index 4d45b8e..98de541 100644
    --- a/src/apps/kpunch/dvxbasic/ide/ideProperties.c
    +++ b/src/apps/kpunch/dvxbasic/ide/ideProperties.c
    @@ -293,12 +293,12 @@ static int32_t getDataFieldNames(const DsgnStateT *ds, const char *dataSourceNam
         }
     
         // Resolve SQL functions via dlsym
    -    SqlOpenFnT       sqlOpen       = (SqlOpenFnT)dlsym(NULL, "_dvxSqlOpen");
    -    SqlCloseFnT      sqlClose      = (SqlCloseFnT)dlsym(NULL, "_dvxSqlClose");
    -    SqlQueryFnT      sqlQuery      = (SqlQueryFnT)dlsym(NULL, "_dvxSqlQuery");
    -    SqlFieldCountFnT sqlFieldCount = (SqlFieldCountFnT)dlsym(NULL, "_dvxSqlFieldCount");
    -    SqlFieldNameFnT  sqlFieldName  = (SqlFieldNameFnT)dlsym(NULL, "_dvxSqlFieldName");
    -    SqlFreeResultFnT sqlFreeResult = (SqlFreeResultFnT)dlsym(NULL, "_dvxSqlFreeResult");
    +    SqlOpenFnT       sqlOpen       = (SqlOpenFnT)dlsym(NULL, DVX_SYM("dvxSqlOpen"));
    +    SqlCloseFnT      sqlClose      = (SqlCloseFnT)dlsym(NULL, DVX_SYM("dvxSqlClose"));
    +    SqlQueryFnT      sqlQuery      = (SqlQueryFnT)dlsym(NULL, DVX_SYM("dvxSqlQuery"));
    +    SqlFieldCountFnT sqlFieldCount = (SqlFieldCountFnT)dlsym(NULL, DVX_SYM("dvxSqlFieldCount"));
    +    SqlFieldNameFnT  sqlFieldName  = (SqlFieldNameFnT)dlsym(NULL, DVX_SYM("dvxSqlFieldName"));
    +    SqlFreeResultFnT sqlFreeResult = (SqlFreeResultFnT)dlsym(NULL, DVX_SYM("dvxSqlFreeResult"));
     
         if (!sqlOpen || !sqlClose || !sqlQuery || !sqlFieldCount || !sqlFieldName || !sqlFreeResult) {
             return 0;
    @@ -417,12 +417,12 @@ static int32_t getTableNames(const char *dbName, char (**outNames)[DSGN_MAX_NAME
             return 0;
         }
     
    -    SqlOpenFnT       sqlOpen       = (SqlOpenFnT)dlsym(NULL, "_dvxSqlOpen");
    -    SqlCloseFnT      sqlClose      = (SqlCloseFnT)dlsym(NULL, "_dvxSqlClose");
    -    SqlQueryFnT      sqlQuery      = (SqlQueryFnT)dlsym(NULL, "_dvxSqlQuery");
    -    SqlNextFnT       sqlNext       = (SqlNextFnT)dlsym(NULL, "_dvxSqlNext");
    -    SqlFieldTextFnT  sqlFieldText  = (SqlFieldTextFnT)dlsym(NULL, "_dvxSqlFieldText");
    -    SqlFreeResultFnT sqlFreeResult = (SqlFreeResultFnT)dlsym(NULL, "_dvxSqlFreeResult");
    +    SqlOpenFnT       sqlOpen       = (SqlOpenFnT)dlsym(NULL, DVX_SYM("dvxSqlOpen"));
    +    SqlCloseFnT      sqlClose      = (SqlCloseFnT)dlsym(NULL, DVX_SYM("dvxSqlClose"));
    +    SqlQueryFnT      sqlQuery      = (SqlQueryFnT)dlsym(NULL, DVX_SYM("dvxSqlQuery"));
    +    SqlNextFnT       sqlNext       = (SqlNextFnT)dlsym(NULL, DVX_SYM("dvxSqlNext"));
    +    SqlFieldTextFnT  sqlFieldText  = (SqlFieldTextFnT)dlsym(NULL, DVX_SYM("dvxSqlFieldText"));
    +    SqlFreeResultFnT sqlFreeResult = (SqlFreeResultFnT)dlsym(NULL, DVX_SYM("dvxSqlFreeResult"));
     
         if (!sqlOpen || !sqlClose || !sqlQuery || !sqlNext || !sqlFieldText || !sqlFreeResult) {
             return 0;
    diff --git a/src/apps/kpunch/dvxbasic/langref.dhs b/src/apps/kpunch/dvxbasic/langref.dhs
    index 2526830..6ee8280 100644
    --- a/src/apps/kpunch/dvxbasic/langref.dhs
    +++ b/src/apps/kpunch/dvxbasic/langref.dhs
    @@ -100,13 +100,17 @@ When mixing types in expressions, values are automatically promoted to a common
     
     .h2 Assignment Conversion
     
    -Storing a value into a variable declared as Integer, Long or Single (by suffix, by AS clause, by DEFINT/DEFLNG/DEFSNG, or as a typed parameter, TYPE field or array element) converts the value to that type. Integer and Long round to the nearest whole number, with halves rounding to the nearest even number (2.5 becomes 2, 3.5 becomes 4), and raise error 6 (Overflow) when the result does not fit. Single keeps about 7 significant digits. A variable with no declared type keeps whatever type the assigned value has.
    +Storing a value into a variable declared as Integer, Long, Single or Boolean (by suffix, by AS clause, by DEFINT/DEFLNG/DEFSNG, or as a typed parameter, TYPE field or array element) converts the value to that type. Integer and Long round to the nearest whole number, with halves rounding to the nearest even number (2.5 becomes 2, 3.5 becomes 4), and raise error 6 (Overflow) when the result does not fit. Single keeps about 7 significant digits, so PRINT shows at most 7 digits of it. Boolean stores True for any non-zero number or non-empty string and False otherwise. A variable declared with DIM but without a type is Single (or the DEFtype for its first letter). A variable that is never declared keeps whatever type the assigned value has.
     
     .code
     Dim n As Integer
     n = 3.7            ' n is 4
     n = 40000          ' Error 6: Overflow
    -x = 3.7            ' x has no declared type and keeps 3.7
    +Dim b As Boolean
    +b = 3              ' b is True
    +Dim s
    +s = 1 / 3          ' s is Single: prints 0.3333333
    +x = 1 / 3          ' x was never declared and keeps the Double: prints 0.333333333333333
     .endcode
     
     .h2 Boolean Values
    @@ -275,11 +279,14 @@ DIM variable AS type
     DIM variable(upperBound) AS type
     DIM variable(lower TO upper) AS type
     DIM variable(dim1, dim2, ...) AS type
    +DIM variable() AS type
     DIM variable AS UdtName
     DIM variable AS STRING * n
     DIM SHARED variable AS type
     .endcode
     
    +An array declared with bounds is a fixed array. DIM variable() with empty parentheses declares a dynamic array that has no storage until a REDIM gives it bounds; using it before then raises error 13. An array that is first created by REDIM, and an array parameter, are dynamic too (see ERASE for the difference).
    +
     Multiple variables can be declared at module level so their values persist between procedure calls. Arrays can have up to 8 dimensions. A variable declared with type suffix does not need the AS clause:
     
     .code
    @@ -306,7 +313,7 @@ Fixed-length strings (STRING * n) are padded with spaces and truncated when assi
     
     .h2 REDIM
     
    -Reallocates a dynamic array, optionally preserving existing data.
    +Reallocates an array, optionally preserving existing data. The array may have been declared with DIM name() or with bounds, or not declared at all (REDIM then declares a dynamic array).
     
     .code
     REDIM array(newBounds) AS type
    @@ -344,6 +351,7 @@ Defines a user-defined type (record/structure).
     .code
     TYPE TypeName
         fieldName AS type
    +    fieldName AS STRING * n
         ...
     END TYPE
     .endcode
    @@ -351,7 +359,7 @@ END TYPE
     .code
     Type PersonType
         firstName As String
    -    lastName  As String
    +    lastName  As String * 20
         age       As Integer
     End Type
     
    @@ -360,7 +368,7 @@ p.firstName = "Scott"
     p.age = 30
     .endcode
     
    -UDT fields can themselves be UDTs (nested types).
    +UDT fields can themselves be UDTs (nested types), and field access chains through array elements too: shapes(i).origin.x = 5. A new instance starts with numeric fields at 0, Boolean fields False, variable strings empty and STRING * n fields padded to n spaces. TYPE variables can be written to and read from RANDOM and BINARY files with PUT and GET (see GET / PUT).
     
     .h2 DECLARE
     
    @@ -451,14 +459,16 @@ The LET keyword is optional and supported for compatibility.
     
     .h2 SWAP
     
    -Exchanges the values of two variables. The variables must be the same type.
    +Exchanges the values of two variables or array elements. The two operands should be the same type.
     
     .code
     SWAP variable1, variable2
    +SWAP array(index), variable
     .endcode
     
     .code
     Swap a, b
    +Swap names(i), names(j)
     .endcode
     
     .h2 SET
    @@ -479,7 +489,7 @@ For ordinary numeric or string assignment, SET is not used.
     
     .h2 ERASE
     
    -Frees the memory of a dynamic array and resets it to undimensioned state. Fixed-size arrays (declared with constant bounds) reset their elements but keep their shape.
    +Frees the memory of a dynamic array (declared with DIM name(), created by REDIM, or an array parameter) and resets it to the undimensioned state; using it again before a REDIM raises error 13. A fixed array (declared with bounds) keeps its bounds and has every element reset to zero, an empty string, False or a fresh TYPE instance.
     
     .code
     ERASE arrayName
    @@ -673,7 +683,8 @@ Wend
     Defines a subroutine (no return value).
     
     .code
    -SUB name ([BYVAL] [OPTIONAL] param AS type, ...)
    +SUB name ([BYVAL | BYREF] [OPTIONAL] param AS type, ...)
    +SUB name (arrayParam() AS type, ...)
         statements
     END SUB
     .endcode
    @@ -684,7 +695,7 @@ Sub Greet(ByVal name As String)
     End Sub
     .endcode
     
    -Parameters are passed by reference by default. Use ByVal for value semantics; there is no separate ByRef keyword (omitting ByVal is the by-reference form). Writes to a by-reference parameter update the caller's variable, including individual array elements: `bump a(3)` passed to a by-reference parameter will modify `a(3)` in place. Use EXIT SUB to return early. A SUB is called either with or without parentheses; when used as a statement, parentheses are optional:
    +Parameters are passed by reference by default; BYREF may be written out to say so, and BYVAL selects value semantics. Writes to a by-reference parameter update the caller's variable, including individual array elements: `bump a(3)` passed to a by-reference parameter will modify `a(3)` in place. A parameter written as name() receives a whole array by reference; the caller passes `arr()` (or just `arr`), and the SUB may index, REDIM or ERASE it. Use EXIT SUB to return early. A SUB is called either with or without parentheses; when used as a statement, parentheses are optional:
     
     .code
     Greet "World"
    @@ -692,6 +703,18 @@ Greet("World")
     Call Greet("World")
     .endcode
     
    +.code
    +Sub Fill(v() As Integer, ByRef count As Integer)
    +    ReDim v(9) As Integer
    +    v(9) = 7
    +    count = count + 1
    +End Sub
    +
    +Dim a(3) As Integer
    +Dim n As Integer
    +Fill a(), n
    +.endcode
    +
     .h3 Optional Parameters
     
     Mark a parameter OPTIONAL to allow callers to omit it. An optional parameter must be positioned after all required parameters and receives an empty/zero default when not supplied.
    @@ -846,7 +869,7 @@ Special functions inside PRINT:
     
     .list
     .item SPC(n) -- print n spaces
    -.item TAB(n) -- advance to column n (first column is 1)
    +.item TAB(n) -- advance to column n (first column is 1); when the cursor is already past column n, a new line is started first
     .endlist
     
     .code
    @@ -857,12 +880,13 @@ Print #1, "Written to file"
     
     .h3 PRINT USING
     
    -PRINT USING formats each following expression according to a format string and prints the result. If more expressions are supplied than the format string consumes, the format string is reused from the start for each.
    +PRINT USING formats each following expression according to a format string and prints the result. The format string is a mix of fields and literal text: each expression consumes the next field, the literal text before it is printed as it is, and the literal text after the last field is printed at the end. If more expressions are supplied than there are fields, the format string is reused from the start.
     
     .table
       Format character   Meaning
       ----------------   -------
    -  #                  Digit (replaced with a digit or space to pad on the left)
    +  #                  Digit position; unused positions on the left become spaces
    +  0                  Digit position that is always printed (zero padded); the first 0 sets the minimum number of digits
       .                  Decimal-point position
       ,                  Insert thousands separator
       +                  At start or end: always show a sign character
    @@ -873,15 +897,22 @@ PRINT USING formats each following expression according to a format string and p
       !                  First character of a string only
       &                  Entire string (variable length)
       \ ... \            Fixed-length string field (width = 2 + number of spaces between backslashes)
    +  _                  The next character is printed literally
     .endtable
     
    +A numeric field is as wide as its format characters; the value is right-aligned in it and a negative sign (or a leading +) floats directly in front of the first digit. A value that does not fit is printed in full with a leading %. A string expression must meet a string field and a numeric expression a numeric field, otherwise error 13 (Type mismatch) is raised.
    +
     .code
    -Print Using "###.##"; 3.14159             ' "  3.14"
    -Print Using "$$#,##0.00"; 1234567.89      ' "$1,234,567.89"
    +Print Using "###.##"; -3.14159            ' " -3.14"
    +Print Using "+###"; 42                    ' " +42"
    +Print Using "$$#,##0.00"; 1234.5          ' " $1,234.50"
     Print Using "**#,##0.00"; 42.5            ' "*****42.50"
    +Print Using "###"; 12345                  ' "%12345"
     Print Using "####.####^^^^"; 0.000123     ' scientific notation
     Print Using "\  \"; "Hello"               ' fixed-width 4-char: "Hell"
    -Print Using "& likes &"; name$; food$
    +Print Using "& likes &"; name$; food$     ' "Scott likes pizza"
    +Print Using "Total: ###.## units"; 7.5    ' "Total:   7.50 units"
    +Print Using "## and ##"; 1; 2; 3          ' " 1 and  2 3 and "
     .endcode
     
     .h2 INPUT
    @@ -926,6 +957,7 @@ Restore
     .index RESUME NEXT
     .index ERROR
     .index ERR
    +.index ERL
     .index SHELL
     .index SLEEP
     .index RANDOMIZE
    @@ -944,7 +976,7 @@ RESUME NEXT             ' Continue at the next statement after the error
     ERROR n                 ' Raise a runtime error with error number n
     .endcode
     
    -The ERR keyword returns the current error number in expressions (it is 0 when no error is active). RESUME or RESUME NEXT executed while no error is active raises error 20 (RESUME without error).
    +The ERR keyword returns the current error number in expressions and ERL the source line the error was raised on (both are 0 when no error is active, and both reset after RESUME). RESUME or RESUME NEXT executed while no error is active raises error 20 (RESUME without error).
     
     .code
     On Error GoTo ErrorHandler
    @@ -952,7 +984,7 @@ Open "missing.txt" For Input As #1
     Exit Sub
     
     ErrorHandler:
    -    Print "Error number:"; Err
    +    Print "Error number:"; Err; "on line"; Erl
         Resume Next
     .endcode
     
    @@ -973,13 +1005,14 @@ ErrorHandler:
       26       FOR loop nesting too deep
       51       Internal error (bad opcode)
       52       Bad file number or file not open
    -  53       File not found
    +  53       File not found (OPEN for INPUT, KILL, FILELEN, ...)
       54       Bad file mode
       58       File already exists or rename failed
    -  59       Bad record length (OPEN ... LEN must be 1 to 32767)
    +  59       Bad record length (OPEN ... LEN must be 1 to 32767; GET/PUT data longer than LEN)
       67       Too many files open
       75       Path/file access error
       76       Path not found
    +  1000     No form runtime (a form or control statement ran without the form runtime, e.g. under a headless runner)
     .endtable
     
     .h2 SHELL
    @@ -1124,10 +1157,28 @@ GET #channel, [recordNum], variable
     PUT #channel, [recordNum], variable
     .endcode
     
    -In RANDOM mode recordNum is a 1-based record number and each record is LEN bytes (see OPEN). Numbers are stored in their binary form (Integer 2 bytes, Long 4, Single 4, Double 8); a String is stored as a 2-byte length followed by its characters.
    +In RANDOM mode recordNum is a 1-based record number and each record is LEN bytes (see OPEN). Numbers are stored in their binary form (Integer and Boolean 2 bytes, Long 4, Single 4, Double 8); a String is stored as a 2-byte length followed by its characters. PUT always writes a whole record, padding the unused part with zero bytes, and GET always advances to the next record; data longer than LEN raises error 59.
     
     In BINARY mode recordNum is a 1-based byte position. Numbers are stored as in RANDOM mode. PUT writes the characters of a String with no length prefix, and GET reads as many bytes as the String variable currently holds, so set its length first (for example with SPACE$ or by declaring it as STRING * n).
     
    +The variable may be a TYPE variable. Its fields are packed in declaration order with the sizes above; a STRING * n field takes exactly n bytes (padded with spaces), a variable-length String field is stored with its 2-byte length prefix in both modes, and nested TYPE fields are packed inline. GET fills the fields of the existing variable in place.
    +
    +The variable may also be an array element, arr(i), of any type including a TYPE: PUT writes that element and GET reads into it. The index expression of a GET target is evaluated twice (once to fetch the element, once to store it back), so keep it free of side effects.
    +
    +.code
    +Type RecT
    +    id    As Integer
    +    score As Double
    +    nm    As String * 6
    +End Type
    +Dim r As RecT
    +Open "recs.dat" For Random As #1 Len = 32
    +r.id = 1
    +Put #1, 1, r            ' 16 bytes of fields, padded to a 32-byte record
    +Get #1, 1, r
    +Close #1
    +.endcode
    +
     .code
     Dim buf As String
     Open "raw.bin" For Binary As #1
    @@ -1271,7 +1322,7 @@ WEND
     
     .h2 FILELEN
     
    -Returns the length of a file in bytes.
    +Returns the length of a file in bytes. A file that does not exist raises error 53 (File not found).
     
     .code
     bytes = FILELEN(filename$)
    @@ -1341,12 +1392,14 @@ s$ = FORMAT$(value, fmt$)
     
     .code
     Print Format$(3.14159, "###.##")      ' "  3.14"
    +Print Format$(-7, "+###")             ' "  -7"
     Print Format$(42, "00000")            ' "00042"
     Print Format$(1234.5, "#,##0.00")     ' "1,234.50"
    +Print Format$(5, "#,##0.00")          ' "    5.00"
     Print Format$(0.5, "PERCENT")         ' "50%"
     .endcode
     
    -The accepted format characters are # (digit or pad space), 0 (digit or pad zero), . (decimal point), , (thousands separator), + and - (sign placement), $$ (floating dollar sign), ** (asterisk fill), and the literal word PERCENT (multiplies by 100 and appends %). See PRINT USING for details on each.
    +The whole format string is one numeric field. The accepted format characters are # (digit or pad space), 0 (digit that is always printed; zero pads up to the first 0), . (decimal point), , (thousands separator), + and - (sign placement), $$ (floating dollar sign), ** (asterisk fill), ^^^^ (scientific notation) and the literal word PERCENT (multiplies by 100 and appends %). Unlike PRINT USING, a value wider than the field is printed in full without a % marker. See PRINT USING for details on each character.
     
     .h2 MID$ Assignment
     
    @@ -1468,10 +1521,10 @@ Me.BackColor = RGB(0, 0, 128)  ' dark blue background
       EOF(channel)                Boolean   True if the file pointer is at end of file
       FREEFILE                    Integer   Next available file channel number (1..16)
       INPUT$(n, #channel)         String    Reads exactly n characters from the file
    -  LOC(channel)                Long      Current read/write position in the file
    +  LOC(channel)                Long      BINARY: position of the last byte read or written (0 at the start); RANDOM: number of the last record read or written; sequential: number of 128-byte blocks read or written
       LOF(channel)                Long      Length of the file in bytes
    -  SEEK(channel)               Long      Current file position (function form)
    -  FILELEN(path$)              Long      Length of the named file in bytes (no OPEN needed)
    +  SEEK(channel)               Long      1-based position of the next byte to be read or written
    +  FILELEN(path$)              Long      Length of the named file in bytes (no OPEN needed; error 53 when missing)
       GETATTR(path$)              Integer   File attribute bits (see vbReadOnly, vbHidden, etc.)
       CURDIR$                     String    Current working directory
       DIR$(pattern$)              String    First filename matching pattern, or ""
    @@ -1533,9 +1586,10 @@ DVX BASIC supports Visual Basic-style forms and controls for building graphical
     .code
     LOAD FormName
     UNLOAD FormName
    +UNLOAD Me
     .endcode
     
    -LOAD creates the form and its controls in memory. It fires Form_Load when the form is first loaded. UNLOAD destroys the form, firing Form_QueryUnload (which can cancel the close) and then Form_Unload. The form name here is the literal name of the form as it appears in its .frm file.
    +LOAD creates the form and its controls in memory. It fires Form_Load when the form is first loaded. UNLOAD destroys the form, firing Form_QueryUnload (which can cancel the close) and then Form_Unload. The form name here is the literal name of the form as it appears in its .frm file; UNLOAD Me unloads the form whose event handler is running.
     
     .h2 Showing and Hiding Forms
     
    @@ -1568,6 +1622,14 @@ Label1.Caption = "Name: " & name$
     x = Text1.Left
     .endcode
     
    +A control on another form is reached through that form's name, or through a variable holding a form reference: FormName.ControlName.Property works in both assignments and expressions, and so does FormName.ControlName.Method. Me.ControlName.Property does the same for the current form.
    +
    +.code
    +Form2.Label1.Caption = "From Form1"
    +total = Form2.List1.ListCount
    +Form2.List1.Clear
    +.endcode
    +
     .h2 Method Calls
     
     .code
    diff --git a/src/apps/kpunch/dvxbasic/runtime/basErrors.h b/src/apps/kpunch/dvxbasic/runtime/basErrors.h
    index e793057..f0e5913 100644
    --- a/src/apps/kpunch/dvxbasic/runtime/basErrors.h
    +++ b/src/apps/kpunch/dvxbasic/runtime/basErrors.h
    @@ -55,5 +55,6 @@
     #define BAS_ERR_PATH_FILE_ACCESS   75
     #define BAS_ERR_PATH_NOT_FOUND     76
     #define BAS_ERR_EXTERNAL_NOT_FOUND 453
    +#define BAS_ERR_NO_FORM_RUNTIME    1000  // form/control statement with no form runtime attached (headless run)
     
     #endif
    diff --git a/src/apps/kpunch/dvxbasic/runtime/values.c b/src/apps/kpunch/dvxbasic/runtime/values.c
    index 31a4549..c45648b 100644
    --- a/src/apps/kpunch/dvxbasic/runtime/values.c
    +++ b/src/apps/kpunch/dvxbasic/runtime/values.c
    @@ -61,6 +61,7 @@ int32_t basArrayIndex(BasArrayT *arr, int32_t *indices, int32_t ndims);
     BasArrayT *basArrayNew(int32_t dims, int32_t *lbounds, int32_t *ubounds, uint8_t elementType);
     BasArrayT *basArrayRef(BasArrayT *arr);
     void basArrayUnref(BasArrayT *arr);
    +double basParseNumber(const char *s);
     BasStringT *basStringAlloc(int32_t cap);
     int32_t basStringCompare(const BasStringT *a, const BasStringT *b);
     int32_t basStringCompareCI(const BasStringT *a, const BasStringT *b);
    @@ -85,7 +86,6 @@ BasValueT basValInteger(int16_t v);
     bool basValIsTruthy(BasValueT v);
     BasValueT basValLong(int32_t v);
     BasValueT basValObject(void *obj);
    -double basParseNumber(const char *s);
     void basValRelease(BasValueT *v);
     bool basValRoundToInt32(BasValueT v, int32_t lo, int32_t hi, int32_t *out);
     BasValueT basValSingle(float v);
    @@ -228,6 +228,81 @@ void basArrayUnref(BasArrayT *arr) {
     }
     
     
    +// ============================================================
    +// Value constructors / refcount helpers
    +// ============================================================
    +double basParseNumber(const char *s) {
    +    if (!s) {
    +        return 0.0;
    +    }
    +
    +    while (*s == ' ' || *s == '\t') {
    +        s++;
    +    }
    +
    +    // &H / &O prefixed integers (VAL("&HFF") = 255).
    +    if (s[0] == '&' && (s[1] == 'H' || s[1] == 'h' || s[1] == 'O' || s[1] == 'o')) {
    +        int32_t radix = (s[1] == 'H' || s[1] == 'h') ? BAS_RADIX_HEX : BAS_RADIX_OCT;
    +        return (double)(int32_t)strtoul(s + 2, NULL, radix);
    +    }
    +
    +    // Decimal: hand-scan the accepted span so atof's extra syntax (hex
    +    // floats, inf, nan) is never reached, and 'D' exponents are accepted.
    +    char    buf[BAS_NUM_FORMAT_BUF_LEN];
    +    int32_t len = 0;
    +    const char *p = s;
    +
    +    if (*p == '+' || *p == '-') {
    +        buf[len++] = *p++;
    +    }
    +
    +    bool sawDigit = false;
    +
    +    while (isdigit((unsigned char)*p) && len < BAS_NUM_FORMAT_BUF_LEN - 1) {
    +        buf[len++] = *p++;
    +        sawDigit   = true;
    +    }
    +
    +    if (*p == '.' && len < BAS_NUM_FORMAT_BUF_LEN - 1) {
    +        buf[len++] = *p++;
    +
    +        while (isdigit((unsigned char)*p) && len < BAS_NUM_FORMAT_BUF_LEN - 1) {
    +            buf[len++] = *p++;
    +            sawDigit   = true;
    +        }
    +    }
    +
    +    if (!sawDigit) {
    +        return 0.0;
    +    }
    +
    +    if ((*p == 'E' || *p == 'e' || *p == 'D' || *p == 'd') && len < BAS_NUM_FORMAT_BUF_LEN - 3) {
    +        const char *q = p + 1;
    +        char        expBuf[BAS_NUM_FORMAT_BUF_LEN];
    +        int32_t     expLen = 0;
    +
    +        if (*q == '+' || *q == '-') {
    +            expBuf[expLen++] = *q++;
    +        }
    +
    +        if (isdigit((unsigned char)*q)) {
    +            while (isdigit((unsigned char)*q) && expLen < BAS_NUM_FORMAT_BUF_LEN - 1) {
    +                expBuf[expLen++] = *q++;
    +            }
    +
    +            if (len + 1 + expLen < BAS_NUM_FORMAT_BUF_LEN) {
    +                buf[len++] = 'e';
    +                memcpy(buf + len, expBuf, expLen);
    +                len += expLen;
    +            }
    +        }
    +    }
    +
    +    buf[len] = '\0';
    +    return atof(buf);
    +}
    +
    +
     BasStringT *basStringAlloc(int32_t cap) {
         if (cap < 1) {
             cap = 1;
    @@ -460,81 +535,6 @@ void basUdtUnref(BasUdtT *udt) {
     }
     
     
    -// ============================================================
    -// Value constructors / refcount helpers
    -// ============================================================
    -double basParseNumber(const char *s) {
    -    if (!s) {
    -        return 0.0;
    -    }
    -
    -    while (*s == ' ' || *s == '\t') {
    -        s++;
    -    }
    -
    -    // &H / &O prefixed integers (VAL("&HFF") = 255).
    -    if (s[0] == '&' && (s[1] == 'H' || s[1] == 'h' || s[1] == 'O' || s[1] == 'o')) {
    -        int32_t radix = (s[1] == 'H' || s[1] == 'h') ? BAS_RADIX_HEX : BAS_RADIX_OCT;
    -        return (double)(int32_t)strtoul(s + 2, NULL, radix);
    -    }
    -
    -    // Decimal: hand-scan the accepted span so atof's extra syntax (hex
    -    // floats, inf, nan) is never reached, and 'D' exponents are accepted.
    -    char    buf[BAS_NUM_FORMAT_BUF_LEN];
    -    int32_t len = 0;
    -    const char *p = s;
    -
    -    if (*p == '+' || *p == '-') {
    -        buf[len++] = *p++;
    -    }
    -
    -    bool sawDigit = false;
    -
    -    while (isdigit((unsigned char)*p) && len < BAS_NUM_FORMAT_BUF_LEN - 1) {
    -        buf[len++] = *p++;
    -        sawDigit   = true;
    -    }
    -
    -    if (*p == '.' && len < BAS_NUM_FORMAT_BUF_LEN - 1) {
    -        buf[len++] = *p++;
    -
    -        while (isdigit((unsigned char)*p) && len < BAS_NUM_FORMAT_BUF_LEN - 1) {
    -            buf[len++] = *p++;
    -            sawDigit   = true;
    -        }
    -    }
    -
    -    if (!sawDigit) {
    -        return 0.0;
    -    }
    -
    -    if ((*p == 'E' || *p == 'e' || *p == 'D' || *p == 'd') && len < BAS_NUM_FORMAT_BUF_LEN - 3) {
    -        const char *q = p + 1;
    -        char        expBuf[BAS_NUM_FORMAT_BUF_LEN];
    -        int32_t     expLen = 0;
    -
    -        if (*q == '+' || *q == '-') {
    -            expBuf[expLen++] = *q++;
    -        }
    -
    -        if (isdigit((unsigned char)*q)) {
    -            while (isdigit((unsigned char)*q) && expLen < BAS_NUM_FORMAT_BUF_LEN - 1) {
    -                expBuf[expLen++] = *q++;
    -            }
    -
    -            if (len + 1 + expLen < BAS_NUM_FORMAT_BUF_LEN) {
    -                buf[len++] = 'e';
    -                memcpy(buf + len, expBuf, expLen);
    -                len += expLen;
    -            }
    -        }
    -    }
    -
    -    buf[len] = '\0';
    -    return atof(buf);
    -}
    -
    -
     BasValueT basValBool(bool v) {
         BasValueT val;
         val.type    = BAS_TYPE_BOOLEAN;
    @@ -773,6 +773,29 @@ int32_t basValToInt32(BasValueT v) {
     }
     
     
    +BasValueT basValToInteger(BasValueT v) {
    +    int32_t n = 0;
    +
    +    if (!basValRoundToInt32(v, INT16_MIN, INT16_MAX, &n)) {
    +        double d = basValToNumber(v);
    +        n = isnan(d) ? 0 : ((d < 0) ? INT16_MIN : INT16_MAX);
    +    }
    +
    +    return basValInteger((int16_t)n);
    +}
    +
    +
    +BasValueT basValToLong(BasValueT v) {
    +    int32_t n = 0;
    +
    +    if (!basValRoundToInt32(v, INT32_MIN, INT32_MAX, &n)) {
    +        n = basValToInt32(v);
    +    }
    +
    +    return basValLong(n);
    +}
    +
    +
     double basValToNumber(BasValueT v) {
         switch (v.type) {
             case BAS_TYPE_INTEGER:
    @@ -803,6 +826,11 @@ double basValToNumber(BasValueT v) {
     }
     
     
    +BasValueT basValToSingle(BasValueT v) {
    +    return basValSingle((float)basValToNumber(v));
    +}
    +
    +
     BasValueT basValToString(BasValueT v) {
         if (v.type == BAS_TYPE_STRING) {
             // Normalize a NULL strVal to the empty string so callers can
    diff --git a/src/apps/kpunch/dvxbasic/runtime/values.h b/src/apps/kpunch/dvxbasic/runtime/values.h
    index c4181e9..feb948f 100644
    --- a/src/apps/kpunch/dvxbasic/runtime/values.h
    +++ b/src/apps/kpunch/dvxbasic/runtime/values.h
    @@ -167,12 +167,12 @@ struct BasValueTag {
     
     // Create values.  Out-of-line so they can be resolved via the DXE
     // dynamic-symbol table when basrt.lib is loaded.
    +BasValueT basValBool(bool v);
    +BasValueT basValDouble(double v);
     BasValueT basValInteger(int16_t v);
     BasValueT basValLong(int32_t v);
    -BasValueT basValSingle(float v);
    -BasValueT basValDouble(double v);
    -BasValueT basValBool(bool v);
     BasValueT basValObject(void *obj);
    +BasValueT basValSingle(float v);
     BasValueT basValString(BasStringT *s);
     BasValueT basValStringFromC(const char *text);
     
    @@ -184,12 +184,12 @@ void basValRelease(BasValueT *v);
     
     // Convert a value to a specific type. Returns the converted value.
     // The original is NOT released -- caller manages lifetime.
    +BasValueT basValToBool(BasValueT v);
    +BasValueT basValToDouble(BasValueT v);
     BasValueT basValToInteger(BasValueT v);
     BasValueT basValToLong(BasValueT v);
     BasValueT basValToSingle(BasValueT v);
    -BasValueT basValToDouble(BasValueT v);
     BasValueT basValToString(BasValueT v);
    -BasValueT basValToBool(BasValueT v);
     
     // Get the numeric value as a double (for mixed-type arithmetic).
     double basValToNumber(BasValueT v);
    diff --git a/src/apps/kpunch/dvxbasic/runtime/vm.c b/src/apps/kpunch/dvxbasic/runtime/vm.c
    index ae84eb3..62d8f19 100644
    --- a/src/apps/kpunch/dvxbasic/runtime/vm.c
    +++ b/src/apps/kpunch/dvxbasic/runtime/vm.c
    @@ -81,6 +81,10 @@
     #define BAS_DATE_STR_LEN         32    // DATE$ "MM-DD-YYYY"
     #define BAS_SCI_FMT_LEN          32    // printf format built for PRINT USING ^^^^
     #define BAS_FORMAT_PERCENT       "PERCENT"   // FORMAT$ named format
    +#define BAS_FORMAT_OVERFLOW_MARK '%'         // PRINT USING: value wider than its field
    +#define BAS_USING_ESCAPE         '_'         // PRINT USING: next format character is literal
    +#define BAS_PRINT_ZONE_WIDTH     14          // PRINT comma: width of one print zone
    +#define BAS_LOC_SEQ_BLOCK        128         // LOC on a sequential channel counts 128-byte blocks (QB)
     #define BAS_SCI_MARKER           "^^^^"      // PRINT USING scientific-notation marker
     
     // Directory permission bits for MKDIR on POSIX hosts (rwxr-xr-x).
    @@ -110,13 +114,22 @@ typedef struct BasNumFormatT {
         bool    minusAtEnd;
         bool    asteriskFill;
         bool    dollarFloat;
    -    bool    zeroPad;
    -    bool    formatPad;
    -    int32_t digitsBefore;
    +    bool    markOverflow;   // PRINT USING: prefix '%' when the value does not fit the field
    +    int32_t width;          // field width the result is right-aligned in
    +    int32_t minIntDigits;   // integer digits forced by '0' placeholders (zero padded)
         int32_t digitsAfter;
     } BasNumFormatT;
     
    +// One decoded TYPE layout entry (see BAS_LAYOUT_* in opcodes.h).
    +typedef struct BasLayoutEntryT {
    +    uint8_t type;
    +    int32_t fixedLen;    // STRING: 0 = variable length
    +    int32_t typeId;      // UDT
    +    int32_t fieldCount;  // UDT
    +} BasLayoutEntryT;
    +
     static BasVmResultE   allocArray(BasVmT *vm, int32_t dims, uint8_t elementType, BasArrayT **outArr);
    +static void           arrayClear(BasArrayT *arr);
     static BasVmResultE   boundOp(BasVmT *vm, uint8_t op);
     static int32_t        clampComponent(int32_t c);
     static BasCallFrameT *currentFrame(BasVmT *vm);
    @@ -138,8 +151,12 @@ static BasStringT    *fillString(char ch, int32_t count);
     static void           formatNumber(double n, const BasNumFormatT *f, char *out, size_t outSize);
     static int32_t        forStackFloor(BasVmT *vm);
     static void           forStackTrim(BasVmT *vm, int32_t newDepth);
    +static BasValueT      ioReadScalar(FILE *fp, uint8_t type);
    +static void           ioWriteScalar(FILE *fp, uint8_t type, BasValueT val);
    +static bool           layoutNext(const BasStringT *layout, int32_t *pos, BasLayoutEntryT *out);
     static void           localNow(struct tm *out, int32_t *outUsec);
     static int32_t        nextStatementPc(BasVmT *vm);
    +static BasVmResultE   noFormRuntime(BasVmT *vm);
     static bool           operandFits(BasVmT *vm, int32_t size);
     static void           parseNumFormat(const char *fmt, int32_t fmtLen, BasNumFormatT *f, bool *outSci);
     static bool           pop(BasVmT *vm, BasValueT *val);
    @@ -149,6 +166,7 @@ static BasVmResultE   popFileChannel(BasVmT *vm, int32_t *outChannel, bool requi
     static bool           popStringArg(BasVmT *vm, BasValueT *out);
     static void           preserveElements(const BasArrayT *oldArr, BasArrayT *newArr);
     static void           primeModuleFrame(BasVmT *vm);
    +static void           printText(BasVmT *vm, const char *text, bool newline);
     static bool           push(BasVmT *vm, BasValueT val);
     static void           putBounded(char *out, int32_t *idx, int32_t limit, char c);
     static BasStringT    *readField(FILE *fp);
    @@ -157,16 +175,136 @@ static BasStringT    *readLine(FILE *fp);
     static void           readRaw(FILE *fp, void *dst, size_t size);
     static uint16_t       readUint16(BasVmT *vm);
     static uint8_t        readUint8(BasVmT *vm);
    +static long           recordBegin(BasVmT *vm, int32_t channel, int32_t recno);
    +static BasVmResultE   recordEnd(BasVmT *vm, int32_t channel, long start, bool writing);
     static void           releaseVmState(BasVmT *vm);
     static BasVmResultE   roundedOperand(BasVmT *vm, BasValueT v, int32_t *out);
     static bool           runSubLoop(BasVmT *vm, int32_t savedPc, int32_t savedCallDepth, bool savedRunning);
     static void           runtimeError(BasVmT *vm, int32_t errNum, const char *msg);
     static void           seekRecord(BasVmT *vm, int32_t channel, int32_t recno);
    +static bool           storeElement(BasVmT *vm, BasValueT *target, BasValueT val);
     static void           storeValue(BasValueT *target, BasValueT val);
     static int32_t        stringFind(const BasStringT *haystack, int32_t start, const BasStringT *needle);
     static BasValueT      strValue(BasStringT *s);
     static BasValueT      udtDetach(BasValueT val);
    +static BasUdtT       *udtFromLayout(BasVmT *vm, const BasStringT *layout, int32_t *pos, int32_t typeId, int32_t fieldCount, int32_t depth);
    +static BasVmResultE   udtGet(BasVmT *vm, FILE *fp, BasUdtT *udt, const BasStringT *layout, int32_t *pos, int32_t depth);
    +static BasVmResultE   udtPut(BasVmT *vm, FILE *fp, const BasUdtT *udt, const BasStringT *layout, int32_t *pos, int32_t depth);
    +static int32_t        usingFieldLen(const char *fmt, int32_t fmtLen, int32_t pos);
    +static BasStringT    *usingFormat(BasVmT *vm, const BasStringT *fmt, BasValueT val, bool last);
     static bool           validArrayDims(BasVmT *vm, int32_t dims);
    +static BasValueT      zeroValue(uint8_t type);
    +
    +
    +// Shared body of OP_DIM_ARRAY / OP_REDIM: pops the UDT typeId/fieldCount
    +// pair (UDT element type only) and the (lbound, ubound) pairs for each
    +// dimension, allocates the array and seeds UDT elements with fresh
    +// instances.  On success *outArr holds a refCount-1 array.
    +static BasVmResultE allocArray(BasVmT *vm, int32_t dims, uint8_t elementType, BasArrayT **outArr) {
    +    if (!validArrayDims(vm, dims)) {
    +        return BAS_VM_ERROR;
    +    }
    +
    +    int32_t   udtTypeId   = -1;
    +    int32_t   udtFieldCnt = 0;
    +    BasValueT layoutVal;
    +
    +    memset(&layoutVal, 0, sizeof(layoutVal));
    +
    +    if (elementType == BAS_TYPE_UDT) {
    +        BasValueT fcVal;
    +        BasValueT tiVal;
    +
    +        if (!popArgs(vm, 3, &fcVal, &tiVal, &layoutVal)) {
    +            return BAS_VM_STACK_UNDERFLOW;
    +        }
    +
    +        udtFieldCnt = basValToInt32(fcVal);
    +        udtTypeId   = basValToInt32(tiVal);
    +        basValRelease(&fcVal);
    +        basValRelease(&tiVal);
    +
    +        if (layoutVal.type != BAS_TYPE_STRING || !layoutVal.strVal) {
    +            basValRelease(&layoutVal);
    +            runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Bad TYPE layout");
    +            return BAS_VM_TYPE_MISMATCH;
    +        }
    +    }
    +
    +    int32_t lbounds[BAS_ARRAY_MAX_DIMS];
    +    int32_t ubounds[BAS_ARRAY_MAX_DIMS];
    +
    +    // Pop bounds in reverse order (last dim first)
    +    for (int32_t d = dims - 1; d >= 0; d--) {
    +        BasValueT ubVal;
    +        BasValueT lbVal;
    +
    +        if (!popArgs(vm, 2, &ubVal, &lbVal)) {
    +            basValRelease(&layoutVal);
    +            return BAS_VM_STACK_UNDERFLOW;
    +        }
    +
    +        ubounds[d] = basValToInt32(ubVal);
    +        lbounds[d] = basValToInt32(lbVal);
    +        basValRelease(&ubVal);
    +        basValRelease(&lbVal);
    +    }
    +
    +    BasArrayT *arr = basArrayNew(dims, lbounds, ubounds, elementType);
    +
    +    if (!arr) {
    +        basValRelease(&layoutVal);
    +        runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of memory allocating array");
    +        return BAS_VM_OUT_OF_MEMORY;
    +    }
    +
    +    // Initialize UDT array elements with proper UDT instances
    +    if (elementType == BAS_TYPE_UDT) {
    +        for (int32_t i = 0; i < arr->totalElements; i++) {
    +            int32_t  pos = 0;
    +            BasUdtT *udt = udtFromLayout(vm, layoutVal.strVal, &pos, udtTypeId, udtFieldCnt, 0);
    +
    +            if (!udt) {
    +                basArrayFree(arr);
    +                basValRelease(&layoutVal);
    +                return BAS_VM_ERROR;
    +            }
    +
    +            arr->elements[i].type   = BAS_TYPE_UDT;
    +            arr->elements[i].udtVal = udt;
    +        }
    +    }
    +
    +    basValRelease(&layoutVal);
    +    *outArr = arr;
    +    return BAS_VM_OK;
    +}
    +
    +
    +// OP_LBOUND / OP_UBOUND: [uint8 dim] array ref on stack -> LONG bound.
    +// ERASE on a fixed array: every element goes back to its declared zero
    +// value (TYPE elements keep their instance and have each field reset).
    +static void arrayClear(BasArrayT *arr) {
    +    for (int32_t i = 0; i < arr->totalElements; i++) {
    +        BasValueT *elem = &arr->elements[i];
    +
    +        if (elem->type == BAS_TYPE_UDT && elem->udtVal) {
    +            BasUdtT *udt = elem->udtVal;
    +
    +            for (int32_t f = 0; f < udt->fieldCount; f++) {
    +                BasValueT zero = zeroValue(udt->fields[f].type);
    +                basValRelease(&udt->fields[f]);
    +                udt->fields[f] = zero;
    +            }
    +
    +            continue;
    +        }
    +
    +        BasValueT zero = zeroValue(arr->elementType);
    +        basValRelease(elem);
    +        *elem = zero;
    +    }
    +}
     
     
     bool basVmCallSub(BasVmT *vm, int32_t codeAddr) {
    @@ -875,7 +1013,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
             }
     
             case OP_RET: {
    -            if (vm->callDepth <= 0) {
    +            // The module frame (depth 1) is never popped: a RET reached at
    +            // module level (corrupt bytecode) ends the program, otherwise
    +            // later local accesses would use a frame teardown cannot see.
    +            if (vm->callDepth <= 1) {
                     vm->running = false;
                     return BAS_VM_HALTED;
                 }
    @@ -892,7 +1033,7 @@ BasVmResultE basVmStep(BasVmT *vm) {
                     return BAS_VM_STACK_UNDERFLOW;
                 }
     
    -            if (vm->callDepth <= 0) {
    +            if (vm->callDepth <= 1) {
                     basValRelease(&retVal);
                     vm->running = false;
                     return BAS_VM_HALTED;
    @@ -1240,16 +1381,20 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 return execPrint(vm);
     
             case OP_PRINT_NL:
    -            if (vm->printFn) {
    -                vm->printFn(vm->printCtx, "", true);
    -            }
    +            printText(vm, "", true);
                 break;
     
    -        case OP_PRINT_TAB:
    -            if (vm->printFn) {
    -                vm->printFn(vm->printCtx, "\t", false);
    -            }
    +        case OP_PRINT_TAB: {
    +            // Comma: advance to the next print zone.
    +            int32_t  target = (vm->printColumn / BAS_PRINT_ZONE_WIDTH + 1) * BAS_PRINT_ZONE_WIDTH;
    +            char     spaces[BAS_PRINT_ZONE_WIDTH + 1];
    +            int32_t  count  = target - vm->printColumn;
    +
    +            memset(spaces, ' ', count);
    +            spaces[count] = '\0';
    +            printText(vm, spaces, false);
                 break;
    +        }
     
             case OP_PRINT_SPC_N: {
                 BasValueT nVal;
    @@ -1272,11 +1417,7 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 char spaces[BAS_PRINT_SPC_MAX + 1];
                 memset(spaces, ' ', count);
                 spaces[count] = '\0';
    -
    -            if (vm->printFn) {
    -                vm->printFn(vm->printCtx, spaces, false);
    -            }
    -
    +            printText(vm, spaces, false);
                 break;
             }
     
    @@ -1298,22 +1439,26 @@ BasVmResultE basVmStep(BasVmT *vm) {
                     col = BAS_PRINT_SPC_MAX;
                 }
     
    -            // TAB outputs spaces to reach the specified column
    -            // For simplicity, just output (col-1) spaces
    -            char spaces[BAS_PRINT_SPC_MAX + 1];
    -            int32_t count = col - 1;
    -            memset(spaces, ' ', count);
    -            spaces[count] = '\0';
    -
    -            if (vm->printFn) {
    -                vm->printFn(vm->printCtx, spaces, false);
    +            // TAB(n) pads to column n (1-based).  When the cursor is
    +            // already past it, QB starts a new line first.
    +            if (vm->printColumn > col - 1) {
    +                printText(vm, "", true);
                 }
     
    +            char    spaces[BAS_PRINT_SPC_MAX + 1];
    +            int32_t count = col - 1 - vm->printColumn;
    +
    +            memset(spaces, ' ', count);
    +            spaces[count] = '\0';
    +            printText(vm, spaces, false);
                 break;
             }
     
             case OP_PRINT_USING: {
    -            // Pop value and format string (format is below value on stack)
    +            // [uint8 flags] pop value and format string (format is below
    +            // value on stack); push the format back for the next value,
    +            // then the literal text and formatted field for this one.
    +            uint8_t   flags = readUint8(vm);
                 BasValueT val;
                 BasValueT fmtVal;
     
    @@ -1324,78 +1469,26 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 BasValueT fmtStr = basValToString(fmtVal);
                 basValRelease(&fmtVal);
     
    -            const char *fmt = fmtStr.strVal->data;
    -            int32_t fmtLen  = fmtStr.strVal->len;
    -            char buf[BAS_FORMAT_BUF_SIZE];
    -
    -            if (val.type == BAS_TYPE_STRING) {
    -                // String formatting
    -                const char *src = val.strVal ? val.strVal->data : "";
    -
    -                if (fmtLen > 0 && fmt[0] == '!') {
    -                    // First character only
    -                    buf[0] = src[0] ? src[0] : ' ';
    -                    buf[1] = '\0';
    -                } else if (fmtLen > 0 && fmt[0] == '&') {
    -                    // Entire string
    -                    snprintf(buf, sizeof(buf), "%s", src);
    -                } else if (fmtLen >= 2 && fmt[0] == '\\') {
    -                    // Fixed-width: count characters between backslashes
    -                    int32_t width = 2;
    -                    for (int32_t i = 1; i < fmtLen; i++) {
    -                        if (fmt[i] == '\\') {
    -                            width = i + 1;
    -                            break;
    -                        }
    -                        width = i + 2;
    -                    }
    -
    -                    // The format is a runtime string, so width is
    -                    // attacker-controlled; clamp so the pad loop and the
    -                    // buf[width] terminator stay inside buf (overflowed
    -                    // the interpreter stack otherwise).
    -                    if (width > BAS_FORMAT_BUF_SIZE - 1) {
    -                        width = BAS_FORMAT_BUF_SIZE - 1;
    -                    }
    -
    -                    int32_t srcLen  = (int32_t)strlen(src);
    -                    int32_t copyLen = srcLen < width ? srcLen : width;
    -                    memcpy(buf, src, copyLen);
    -                    for (int32_t i = copyLen; i < width; i++) {
    -                        buf[i] = ' ';
    -                    }
    -                    buf[width] = '\0';
    -                } else {
    -                    snprintf(buf, sizeof(buf), "%s", src);
    -                }
    -            } else {
    -                // Numeric formatting
    -                double        n = basValToNumber(val);
    -                BasNumFormatT f;
    -                bool          sciNotation;
    -
    -                parseNumFormat(fmt, fmtLen, &f, &sciNotation);
    -
    -                if (sciNotation) {
    -                    char    sciFmt[BAS_SCI_FMT_LEN];
    -                    int32_t decimals = f.hasDecimal ? f.digitsAfter : 0;
    -
    -                    snprintf(sciFmt, sizeof(sciFmt), "%%.%dE", (int)decimals);
    -                    snprintf(buf, sizeof(buf), sciFmt, n);
    -                } else {
    -                    formatNumber(n, &f, buf, sizeof(buf));
    -                }
    +            if (flags & BAS_USING_FIRST) {
    +                vm->usingPos = 0;
                 }
     
    +            BasStringT *out = usingFormat(vm, fmtStr.strVal, val, (flags & BAS_USING_LAST) != 0);
                 basValRelease(&val);
     
    -            // Push format string back (PRINT USING reuses it for multiple values)
    +            if (!out) {
    +                basValRelease(&fmtStr);
    +                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "PRINT USING field does not match the value");
    +                return BAS_VM_TYPE_MISMATCH;
    +            }
    +
                 if (!push(vm, fmtStr)) {
    +                basStringUnref(out);
                     return BAS_VM_STACK_OVERFLOW;
                 }
     
    -            // Push formatted result
    -            if (!push(vm, basValStringFromC(buf))) {
    +            if (!push(vm, strValue(out))) {
    +                basStringUnref(out);
                     return BAS_VM_STACK_OVERFLOW;
                 }
     
    @@ -1756,7 +1849,9 @@ BasVmResultE basVmStep(BasVmT *vm) {
             case OP_FILE_WRITE_NL:
             case OP_FILE_GET:
             case OP_FILE_PUT:
    +        case OP_FILE_PUT_UDT:
             case OP_FILE_SEEK:
    +        case OP_FILE_SEEK_GET:
             case OP_FILE_LOF:
             case OP_FILE_LOC:
             case OP_FILE_FREEFILE:
    @@ -1813,6 +1908,12 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 }
                 break;
     
    +        case OP_ERL:
    +            if (!push(vm, basValLong(vm->errorLine))) {
    +                return BAS_VM_STACK_OVERFLOW;
    +            }
    +            break;
    +
             case OP_RESUME:
             case OP_RESUME_NEXT:
                 // RESUME re-executes the statement that raised the error;
    @@ -1825,6 +1926,7 @@ BasVmResultE basVmStep(BasVmT *vm) {
     
                 vm->pc             = (op == OP_RESUME) ? vm->errorPc : vm->errorNextPc;
                 vm->errorNumber    = 0;
    +            vm->errorLine      = 0;
                 vm->errorMsg[0]    = '\0';
                 vm->inErrorHandler = false;
                 break;
    @@ -1854,8 +1956,9 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 if (dims == 0 && elementType == BAS_TYPE_UDT) {
                     BasValueT fieldCountVal;
                     BasValueT typeIdVal;
    +                BasValueT layoutVal;
     
    -                if (!popArgs(vm, 2, &fieldCountVal, &typeIdVal)) {
    +                if (!popArgs(vm, 3, &fieldCountVal, &typeIdVal, &layoutVal)) {
                         return BAS_VM_STACK_UNDERFLOW;
                     }
     
    @@ -1864,11 +1967,19 @@ BasVmResultE basVmStep(BasVmT *vm) {
                     basValRelease(&fieldCountVal);
                     basValRelease(&typeIdVal);
     
    -                BasUdtT *udt = basUdtNew(typeId, fieldCount);
    +                if (layoutVal.type != BAS_TYPE_STRING || !layoutVal.strVal) {
    +                    basValRelease(&layoutVal);
    +                    runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Bad TYPE layout");
    +                    return BAS_VM_TYPE_MISMATCH;
    +                }
    +
    +                int32_t  pos = 0;
    +                BasUdtT *udt = udtFromLayout(vm, layoutVal.strVal, &pos, typeId, fieldCount, 0);
    +
    +                basValRelease(&layoutVal);
     
                     if (!udt) {
    -                    runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of memory allocating TYPE");
    -                    return BAS_VM_OUT_OF_MEMORY;
    +                    return BAS_VM_ERROR;
                     }
     
                     BasValueT udtVal;
    @@ -1876,6 +1987,7 @@ BasVmResultE basVmStep(BasVmT *vm) {
                     udtVal.udtVal = udt;
     
                     if (!push(vm, udtVal)) {
    +                    basUdtUnref(udt);
                         return BAS_VM_STACK_OVERFLOW;
                     }
     
    @@ -1894,6 +2006,7 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 arrVal.arrVal = arr;
     
                 if (!push(vm, arrVal)) {
    +                basArrayUnref(arr);
                     return BAS_VM_STACK_OVERFLOW;
                 }
     
    @@ -2064,8 +2177,13 @@ BasVmResultE basVmStep(BasVmT *vm) {
                     return BAS_VM_SUBSCRIPT_RANGE;
                 }
     
    -            storeValue(&arrRef.arrVal->elements[flatIdx], storeVal);
    +            bool stored = storeElement(vm, &arrRef.arrVal->elements[flatIdx], storeVal);
                 basValRelease(&arrRef);
    +
    +            if (!stored) {
    +                return BAS_VM_TYPE_MISMATCH;
    +            }
    +
                 break;
             }
     
    @@ -2140,8 +2258,13 @@ BasVmResultE basVmStep(BasVmT *vm) {
                     return BAS_VM_ERROR;
                 }
     
    -            storeValue(&elem->udtVal->fields[fieldIdx], storeVal);
    +            bool stored = storeElement(vm, &elem->udtVal->fields[fieldIdx], storeVal);
                 basValRelease(&arrRef);
    +
    +            if (!stored) {
    +                return BAS_VM_TYPE_MISMATCH;
    +            }
    +
                 break;
             }
     
    @@ -2180,6 +2303,7 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 arrVal.arrVal = newArr;
     
                 if (!push(vm, arrVal)) {
    +                basArrayUnref(newArr);
                     return BAS_VM_STACK_OVERFLOW;
                 }
     
    @@ -2187,12 +2311,27 @@ BasVmResultE basVmStep(BasVmT *vm) {
             }
     
             case OP_ERASE: {
    +            // [uint8 mode] A fixed array keeps its bounds and has every
    +            // element reset; a dynamic array is released and stays
    +            // undimensioned until the next REDIM.
    +            uint8_t   mode = readUint8(vm);
                 BasValueT arrRef;
     
                 if (!pop(vm, &arrRef)) {
                     return BAS_VM_STACK_UNDERFLOW;
                 }
     
    +            if (mode == BAS_ERASE_ZERO && arrRef.type == BAS_TYPE_ARRAY && arrRef.arrVal) {
    +                arrayClear(arrRef.arrVal);
    +
    +                if (!push(vm, arrRef)) {
    +                    basValRelease(&arrRef);
    +                    return BAS_VM_STACK_OVERFLOW;
    +                }
    +
    +                break;
    +            }
    +
                 basValRelease(&arrRef);
     
                 // Push an empty/zero value to store back
    @@ -2269,8 +2408,13 @@ BasVmResultE basVmStep(BasVmT *vm) {
                     return BAS_VM_ERROR;
                 }
     
    -            storeValue(&udtRef.udtVal->fields[fieldIdx], storeVal);
    +            bool stored = storeElement(vm, &udtRef.udtVal->fields[fieldIdx], storeVal);
                 basValRelease(&udtRef);
    +
    +            if (!stored) {
    +                return BAS_VM_TYPE_MISMATCH;
    +            }
    +
                 break;
             }
     
    @@ -2326,13 +2470,6 @@ BasVmResultE basVmStep(BasVmT *vm) {
                     bool          sciNotation;
     
                     parseNumFormat(fmt, fmtLen, &f, &sciNotation);
    -
    -                // FORMAT$ has no ** / $$ fills; digit positions before the
    -                // number are zero-filled whenever a 0 placeholder appears.
    -                f.asteriskFill = false;
    -                f.dollarFloat  = false;
    -                f.formatPad    = true;
    -
                     formatNumber(n, &f, buf, sizeof(buf));
                 }
     
    @@ -2396,6 +2533,7 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 if (vm->inErrorHandler) {
                     vm->inErrorHandler = false;
                     vm->errorNumber    = 0;
    +                vm->errorLine      = 0;
                     vm->errorMsg[0]    = '\0';
                 }
     
    @@ -2426,6 +2564,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&propNameVal);
                 basValRelease(&ctrlRefVal);
     
    +            if (!vm->ui.getProp) {
    +                return noFormRuntime(vm);
    +            }
    +
                 if (!push(vm, result)) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -2451,6 +2593,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&value);
                 basValRelease(&propNameVal);
                 basValRelease(&ctrlRefVal);
    +
    +            if (!vm->ui.setProp) {
    +                return noFormRuntime(vm);
    +            }
                 break;
             }
     
    @@ -2527,6 +2673,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&methodNameVal);
                 basValRelease(&ctrlRefVal);
     
    +            if (!vm->ui.callMethod) {
    +                return noFormRuntime(vm);
    +            }
    +
                 if (!push(vm, result)) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -2556,6 +2706,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
     
                 basValRelease(&nameVal);
     
    +            if (!vm->ui.loadForm) {
    +                return noFormRuntime(vm);
    +            }
    +
                 if (!push(vm, basValObject(formRef))) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -2575,6 +2729,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 }
     
                 basValRelease(&formVal);
    +
    +            if (!vm->ui.unloadForm) {
    +                return noFormRuntime(vm);
    +            }
                 break;
             }
     
    @@ -2591,6 +2749,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 }
     
                 basValRelease(&formVal);
    +
    +            if (!vm->ui.showForm) {
    +                return noFormRuntime(vm);
    +            }
                 break;
             }
     
    @@ -2606,6 +2768,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 }
     
                 basValRelease(&formVal);
    +
    +            if (!vm->ui.hideForm) {
    +                return noFormRuntime(vm);
    +            }
                 break;
             }
     
    @@ -2634,6 +2800,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&flagsVal);
                 basValRelease(&msgVal);
     
    +            if (!vm->ui.msgBox) {
    +                return noFormRuntime(vm);
    +            }
    +
                 if (!push(vm, basValInteger((int16_t)result))) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -2666,6 +2836,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&titleVal);
                 basValRelease(&promptVal);
     
    +            if (!vm->ui.inputBox) {
    +                return noFormRuntime(vm);
    +            }
    +
                 if (!push(vm, strValue(result ? result : basStringRef(basEmptyString)))) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -2703,6 +2877,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&typeVal);
                 basValRelease(&formVal);
     
    +            if (!vm->ui.createCtrl) {
    +                return noFormRuntime(vm);
    +            }
    +
                 if (!push(vm, basValObject(ctrlRef))) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -2737,6 +2915,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&typeVal);
                 basValRelease(&formVal);
     
    +            if (!vm->ui.createCtrlEx) {
    +                return noFormRuntime(vm);
    +            }
    +
                 if (!push(vm, basValObject(ctrlRef))) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -2765,6 +2947,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&nameVal);
                 basValRelease(&formVal);
     
    +            if (!vm->ui.findCtrl) {
    +                return noFormRuntime(vm);
    +            }
    +
                 if (!push(vm, basValObject(ctrlRef))) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -2796,6 +2982,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&nameVal);
                 basValRelease(&formVal);
     
    +            if (!vm->ui.findCtrlIdx) {
    +                return noFormRuntime(vm);
    +            }
    +
                 if (!push(vm, basValObject(ctrlRef))) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -2827,6 +3017,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&widthVal);
                 basValRelease(&heightVal);
     
    +            if (!vm->ui.createForm) {
    +                return noFormRuntime(vm);
    +            }
    +
                 if (!push(vm, basValObject(formRef))) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -2855,6 +3049,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
                 basValRelease(&handlerVal);
                 basValRelease(&eventVal);
                 basValRelease(&ctrlVal);
    +
    +            if (!vm->ui.setEvent) {
    +                return noFormRuntime(vm);
    +            }
                 break;
             }
     
    @@ -2875,6 +3073,10 @@ BasVmResultE basVmStep(BasVmT *vm) {
     
                 basValRelease(&nameVal);
                 basValRelease(&formVal);
    +
    +            if (!vm->ui.removeCtrl) {
    +                return noFormRuntime(vm);
    +            }
                 break;
             }
     
    @@ -3351,79 +3553,6 @@ void basVmStepOver(BasVmT *vm) {
     }
     
     
    -// Shared body of OP_DIM_ARRAY / OP_REDIM: pops the UDT typeId/fieldCount
    -// pair (UDT element type only) and the (lbound, ubound) pairs for each
    -// dimension, allocates the array and seeds UDT elements with fresh
    -// instances.  On success *outArr holds a refCount-1 array.
    -static BasVmResultE allocArray(BasVmT *vm, int32_t dims, uint8_t elementType, BasArrayT **outArr) {
    -    if (!validArrayDims(vm, dims)) {
    -        return BAS_VM_ERROR;
    -    }
    -
    -    int32_t udtTypeId   = -1;
    -    int32_t udtFieldCnt = 0;
    -
    -    if (elementType == BAS_TYPE_UDT) {
    -        BasValueT fcVal;
    -        BasValueT tiVal;
    -
    -        if (!popArgs(vm, 2, &fcVal, &tiVal)) {
    -            return BAS_VM_STACK_UNDERFLOW;
    -        }
    -
    -        udtFieldCnt = basValToInt32(fcVal);
    -        udtTypeId   = basValToInt32(tiVal);
    -        basValRelease(&fcVal);
    -        basValRelease(&tiVal);
    -    }
    -
    -    int32_t lbounds[BAS_ARRAY_MAX_DIMS];
    -    int32_t ubounds[BAS_ARRAY_MAX_DIMS];
    -
    -    // Pop bounds in reverse order (last dim first)
    -    for (int32_t d = dims - 1; d >= 0; d--) {
    -        BasValueT ubVal;
    -        BasValueT lbVal;
    -
    -        if (!popArgs(vm, 2, &ubVal, &lbVal)) {
    -            return BAS_VM_STACK_UNDERFLOW;
    -        }
    -
    -        ubounds[d] = basValToInt32(ubVal);
    -        lbounds[d] = basValToInt32(lbVal);
    -        basValRelease(&ubVal);
    -        basValRelease(&lbVal);
    -    }
    -
    -    BasArrayT *arr = basArrayNew(dims, lbounds, ubounds, elementType);
    -
    -    if (!arr) {
    -        runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of memory allocating array");
    -        return BAS_VM_OUT_OF_MEMORY;
    -    }
    -
    -    // Initialize UDT array elements with proper UDT instances
    -    if (elementType == BAS_TYPE_UDT && udtTypeId >= 0) {
    -        for (int32_t i = 0; i < arr->totalElements; i++) {
    -            BasUdtT *udt = basUdtNew(udtTypeId, udtFieldCnt);
    -
    -            if (!udt) {
    -                basArrayFree(arr);
    -                runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of memory allocating TYPE array elements");
    -                return BAS_VM_OUT_OF_MEMORY;
    -            }
    -
    -            arr->elements[i].type   = BAS_TYPE_UDT;
    -            arr->elements[i].udtVal = udt;
    -        }
    -    }
    -
    -    *outArr = arr;
    -    return BAS_VM_OK;
    -}
    -
    -
    -// OP_LBOUND / OP_UBOUND: [uint8 dim] array ref on stack -> LONG bound.
     static BasVmResultE boundOp(BasVmT *vm, uint8_t op) {
         uint8_t   dim = readUint8(vm);
         BasValueT arrRef;
    @@ -4111,18 +4240,25 @@ static BasVmResultE execFileOp(BasVmT *vm, uint8_t op) {
             }
     
             case OP_FILE_GET: {
    -            // Pop type, [current string value], recno, channel; read
    -            // data; push value.  A STRING target carries its current
    -            // value so BINARY mode knows how many bytes to read.
    +            // Pop type, [current string value | udt + layout], recno,
    +            // channel; read data; push value.  A STRING target carries
    +            // its current value so BINARY mode knows how many bytes to
    +            // read; a TYPE target carries the instance to fill in place
    +            // and the compiler's field layout.
                 BasValueT typeVal;
     
                 if (!pop(vm, &typeVal)) {
                     return BAS_VM_STACK_UNDERFLOW;
                 }
     
    -            int32_t dataType = basValToInt32(typeVal);
    -            int32_t strLen   = 0;
    +            int32_t   dataType = basValToInt32(typeVal);
    +            int32_t   strLen   = 0;
    +            BasValueT udtVal;
    +            BasValueT layoutVal;
    +
                 basValRelease(&typeVal);
    +            memset(&udtVal, 0, sizeof(udtVal));
    +            memset(&layoutVal, 0, sizeof(layoutVal));
     
                 if (dataType == BAS_TYPE_STRING) {
                     BasValueT curVal;
    @@ -4133,11 +4269,24 @@ static BasVmResultE execFileOp(BasVmT *vm, uint8_t op) {
     
                     strLen = (curVal.type == BAS_TYPE_STRING && curVal.strVal) ? curVal.strVal->len : 0;
                     basValRelease(&curVal);
    +            } else if (dataType == BAS_TYPE_UDT) {
    +                if (!popArgs(vm, 2, &layoutVal, &udtVal)) {
    +                    return BAS_VM_STACK_UNDERFLOW;
    +                }
    +
    +                if (layoutVal.type != BAS_TYPE_STRING || !layoutVal.strVal || udtVal.type != BAS_TYPE_UDT || !udtVal.udtVal) {
    +                    basValRelease(&layoutVal);
    +                    basValRelease(&udtVal);
    +                    runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not a TYPE instance");
    +                    return BAS_VM_TYPE_MISMATCH;
    +                }
                 }
     
                 BasValueT recnoVal;
     
                 if (!pop(vm, &recnoVal)) {
    +                basValRelease(&layoutVal);
    +                basValRelease(&udtVal);
                     return BAS_VM_STACK_UNDERFLOW;
                 }
     
    @@ -4148,43 +4297,25 @@ static BasVmResultE execFileOp(BasVmT *vm, uint8_t op) {
                 BasVmResultE  chResult = popFileChannel(vm, &channel, true);
     
                 if (chResult != BAS_VM_OK) {
    +                basValRelease(&layoutVal);
    +                basValRelease(&udtVal);
                     return chResult;
                 }
     
    -            FILE *fp = (FILE *)vm->files[channel].handle;
    -            seekRecord(vm, channel, recno);
    +            FILE *fp    = (FILE *)vm->files[channel].handle;
    +            long  start = recordBegin(vm, channel, recno);
     
                 BasValueT result;
                 memset(&result, 0, sizeof(result));
     
                 switch (dataType) {
    -                case BAS_TYPE_INTEGER: {
    -                    int16_t val = 0;
    -                    readRaw(fp, &val, sizeof(val));
    -                    result = basValInteger(val);
    +                case BAS_TYPE_INTEGER:
    +                case BAS_TYPE_LONG:
    +                case BAS_TYPE_SINGLE:
    +                case BAS_TYPE_DOUBLE:
    +                case BAS_TYPE_BOOLEAN:
    +                    result = ioReadScalar(fp, (uint8_t)dataType);
                         break;
    -                }
    -
    -                case BAS_TYPE_LONG: {
    -                    int32_t val = 0;
    -                    readRaw(fp, &val, sizeof(val));
    -                    result = basValLong(val);
    -                    break;
    -                }
    -
    -                case BAS_TYPE_SINGLE: {
    -                    float val = 0.0f;
    -                    readRaw(fp, &val, sizeof(val));
    -                    result = basValSingle(val);
    -                    break;
    -                }
    -
    -                case BAS_TYPE_DOUBLE: {
    -                    double val = 0.0;
    -                    readRaw(fp, &val, sizeof(val));
    -                    result = basValDouble(val);
    -                    break;
    -                }
     
                     case BAS_TYPE_STRING: {
                         // BINARY reads as many raw bytes as the variable
    @@ -4214,12 +4345,35 @@ static BasVmResultE execFileOp(BasVmT *vm, uint8_t op) {
                         break;
                     }
     
    +                case BAS_TYPE_UDT: {
    +                    int32_t      pos = 0;
    +                    BasVmResultE rc  = udtGet(vm, fp, udtVal.udtVal, layoutVal.strVal, &pos, 0);
    +
    +                    basValRelease(&layoutVal);
    +
    +                    if (rc != BAS_VM_OK) {
    +                        basValRelease(&udtVal);
    +                        return rc;
    +                    }
    +
    +                    result = udtVal;
    +                    break;
    +                }
    +
                     default:
                         result = basValInteger(0);
                         break;
                 }
     
    +            BasVmResultE endResult = recordEnd(vm, channel, start, false);
    +
    +            if (endResult != BAS_VM_OK) {
    +                basValRelease(&result);
    +                return endResult;
    +            }
    +
                 if (!push(vm, result)) {
    +                basValRelease(&result);
                     return BAS_VM_STACK_OVERFLOW;
                 }
     
    @@ -4246,33 +4400,17 @@ static BasVmResultE execFileOp(BasVmT *vm, uint8_t op) {
                     return chResult;
                 }
     
    -            FILE *fp = (FILE *)vm->files[channel].handle;
    -            seekRecord(vm, channel, recno);
    +            FILE *fp    = (FILE *)vm->files[channel].handle;
    +            long  start = recordBegin(vm, channel, recno);
     
                 switch (val.type) {
    -                case BAS_TYPE_INTEGER: {
    -                    int16_t v = val.intVal;
    -                    fwrite(&v, sizeof(v), 1, fp);
    +                case BAS_TYPE_INTEGER:
    +                case BAS_TYPE_LONG:
    +                case BAS_TYPE_SINGLE:
    +                case BAS_TYPE_DOUBLE:
    +                case BAS_TYPE_BOOLEAN:
    +                    ioWriteScalar(fp, val.type, val);
                         break;
    -                }
    -
    -                case BAS_TYPE_LONG: {
    -                    int32_t v = val.longVal;
    -                    fwrite(&v, sizeof(v), 1, fp);
    -                    break;
    -                }
    -
    -                case BAS_TYPE_SINGLE: {
    -                    float v = val.sngVal;
    -                    fwrite(&v, sizeof(v), 1, fp);
    -                    break;
    -                }
    -
    -                case BAS_TYPE_DOUBLE: {
    -                    double v = val.dblVal;
    -                    fwrite(&v, sizeof(v), 1, fp);
    -                    break;
    -                }
     
                     case BAS_TYPE_STRING: {
                         // RANDOM records carry an int16 length prefix so GET can
    @@ -4310,9 +4448,50 @@ static BasVmResultE execFileOp(BasVmT *vm, uint8_t op) {
                     }
                 }
     
    -            fflush(fp);
                 basValRelease(&val);
    -            break;
    +            return recordEnd(vm, channel, start, true);
    +        }
    +
    +        case OP_FILE_PUT_UDT: {
    +            // Pop layout, udt, recno, channel; write the packed fields.
    +            BasValueT layoutVal;
    +            BasValueT udtVal;
    +            BasValueT recnoVal;
    +
    +            if (!popArgs(vm, 3, &layoutVal, &udtVal, &recnoVal)) {
    +                return BAS_VM_STACK_UNDERFLOW;
    +            }
    +
    +            int32_t recno = basValToInt32(recnoVal);
    +            basValRelease(&recnoVal);
    +
    +            int32_t       channel;
    +            BasVmResultE  chResult = popFileChannel(vm, &channel, true);
    +
    +            if (chResult == BAS_VM_OK && (layoutVal.type != BAS_TYPE_STRING || !layoutVal.strVal || udtVal.type != BAS_TYPE_UDT || !udtVal.udtVal)) {
    +                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not a TYPE instance");
    +                chResult = BAS_VM_TYPE_MISMATCH;
    +            }
    +
    +            if (chResult != BAS_VM_OK) {
    +                basValRelease(&layoutVal);
    +                basValRelease(&udtVal);
    +                return chResult;
    +            }
    +
    +            FILE        *fp    = (FILE *)vm->files[channel].handle;
    +            long         start = recordBegin(vm, channel, recno);
    +            int32_t      pos   = 0;
    +            BasVmResultE rc    = udtPut(vm, fp, udtVal.udtVal, layoutVal.strVal, &pos, 0);
    +
    +            basValRelease(&layoutVal);
    +            basValRelease(&udtVal);
    +
    +            if (rc != BAS_VM_OK) {
    +                return rc;
    +            }
    +
    +            return recordEnd(vm, channel, start, true);
             }
     
             case OP_FILE_SEEK: {
    @@ -4360,8 +4539,8 @@ static BasVmResultE execFileOp(BasVmT *vm, uint8_t op) {
                 break;
             }
     
    -        case OP_FILE_LOC: {
    -            // Pop channel, push current position
    +        case OP_FILE_SEEK_GET: {
    +            // Pop channel, push the 1-based file position
                 int32_t       channel;
                 BasVmResultE  chResult = popFileChannel(vm, &channel, true);
     
    @@ -4371,7 +4550,6 @@ static BasVmResultE execFileOp(BasVmT *vm, uint8_t op) {
     
                 long pos = ftell((FILE *)vm->files[channel].handle);
     
    -            // QB returns 1-based position
                 if (!push(vm, basValLong((int32_t)(pos + 1)))) {
                     return BAS_VM_STACK_OVERFLOW;
                 }
    @@ -4379,6 +4557,35 @@ static BasVmResultE execFileOp(BasVmT *vm, uint8_t op) {
                 break;
             }
     
    +        case OP_FILE_LOC: {
    +            // Pop channel, push LOC
    +            int32_t       channel;
    +            BasVmResultE  chResult = popFileChannel(vm, &channel, true);
    +
    +            if (chResult != BAS_VM_OK) {
    +                return chResult;
    +            }
    +
    +            // QB: BINARY reports the last byte read or written, RANDOM the
    +            // last record number, sequential modes the 128-byte block count.
    +            long    pos = ftell((FILE *)vm->files[channel].handle);
    +            int32_t loc;
    +
    +            if (vm->files[channel].mode == BAS_FILE_MODE_BINARY) {
    +                loc = (int32_t)pos;
    +            } else if (vm->files[channel].mode == BAS_FILE_MODE_RANDOM) {
    +                loc = (int32_t)(pos / vm->files[channel].recLen);
    +            } else {
    +                loc = (int32_t)(pos / BAS_LOC_SEQ_BLOCK);
    +            }
    +
    +            if (!push(vm, basValLong(loc))) {
    +                return BAS_VM_STACK_OVERFLOW;
    +            }
    +
    +            break;
    +        }
    +
             case OP_FILE_FREEFILE: {
                 // Push the next available file channel number
                 int32_t freeNum = 0;
    @@ -4691,10 +4898,13 @@ static BasVmResultE execFsOp(BasVmT *vm, uint8_t op) {
                 struct stat st;
                 int32_t     size = 0;
     
    -            if (stat(fnStr.strVal->data, &st) == 0) {
    -                size = (int32_t)st.st_size;
    +            if (stat(fnStr.strVal->data, &st) != 0) {
    +                basValRelease(&fnStr);
    +                runtimeError(vm, BAS_ERR_FILE_NOT_FOUND, "File not found");
    +                return BAS_VM_FILE_ERROR;
                 }
     
    +            size = (int32_t)st.st_size;
                 basValRelease(&fnStr);
     
                 if (!push(vm, basValLong(size))) {
    @@ -4983,12 +5193,10 @@ static BasVmResultE execPrint(BasVmT *vm) {
         BasStringT *s = basValFormatString(val);
         basValRelease(&val);
     
    -    if (vm->printFn) {
    -        vm->printFn(vm->printCtx, s->data, false);
    +    printText(vm, s->data, false);
     
    -        if (isNumeric) {
    -            vm->printFn(vm->printCtx, " ", false);
    -        }
    +    if (isNumeric) {
    +        printText(vm, " ", false);
         }
     
         basStringUnref(s);
    @@ -5380,11 +5588,14 @@ static BasStringT *fillString(char ch, int32_t count) {
     }
     
     
    -// Shared numeric formatter for OP_PRINT_USING (numeric branch) and OP_FORMAT
    -// (non-PERCENT branch). Renders n into out per the flags in f. Every cursor
    -// write is bounded against outSize, and every intermediate buffer is sized to
    -// BAS_FORMAT_BUF_SIZE and clamped, so a pathological format string or numeric
    -// magnitude cannot overflow the caller's stack-resident buffer.
    +// Shared numeric formatter for OP_PRINT_USING (numeric fields) and OP_FORMAT.
    +// Renders n per the flags in f, right-aligned in f->width: the sign (or a
    +// trailing sign), the floating dollar, the thousands-separated integer part
    +// zero-extended to f->minIntDigits, the decimals and the trailing sign make
    +// up the body; the remaining positions are filled with spaces or asterisks.
    +// Every cursor write is bounded against outSize, and every intermediate
    +// buffer is sized to BAS_FORMAT_BUF_SIZE and clamped, so a pathological
    +// format string or numeric magnitude cannot overflow the caller's buffer.
     static void formatNumber(double n, const BasNumFormatT *f, char *out, size_t outSize) {
         bool    isNeg    = (n < 0);
         double  absN     = isNeg ? -n : n;
    @@ -5419,93 +5630,73 @@ static void formatNumber(double n, const BasNumFormatT *f, char *out, size_t out
             intPart[sizeof(intPart) - 1] = '\0';
         }
     
    -    // Apply thousands separator.
    -    char fmtIntPart[BAS_FORMAT_BUF_SIZE];
    +    // Zero-extend to the '0' placeholders, then apply the thousands separator.
    +    char    fmtIntPart[BAS_FORMAT_BUF_SIZE];
    +    int32_t intLen  = (int32_t)strlen(intPart);
    +    int32_t zeroPad = f->minIntDigits - intLen;
    +    int32_t dstIdx  = 0;
     
    -    if (f->hasComma) {
    -        int32_t srcLen = (int32_t)strlen(intPart);
    -        int32_t dstIdx = 0;
    -
    -        for (int32_t i = 0; i < srcLen; i++) {
    -            if (i > 0 && (srcLen - i) % 3 == 0) {
    -                putBounded(fmtIntPart, &dstIdx, (int32_t)sizeof(fmtIntPart) - 1, ',');
    -            }
    -
    -            putBounded(fmtIntPart, &dstIdx, (int32_t)sizeof(fmtIntPart) - 1, intPart[i]);
    -        }
    -
    -        fmtIntPart[dstIdx] = '\0';
    -    } else {
    -        strncpy(fmtIntPart, intPart, sizeof(fmtIntPart) - 1);
    -        fmtIntPart[sizeof(fmtIntPart) - 1] = '\0';
    +    if (zeroPad < 0) {
    +        zeroPad = 0;
         }
     
    -    // Sign prefix. FORMAT$ (formatPad) and PRINT USING differ here, so each
    -    // call site's original rule is reproduced exactly.
    -    if (f->formatPad) {
    -        // FORMAT$: leading '-' for negatives whenever a sign position exists,
    -        // leading '+' only for plusAtStart positives.
    -        if (f->plusAtStart || f->plusAtEnd) {
    -            if (isNeg) {
    -                putBounded(out, &idx, limit, '-');
    -            } else if (f->plusAtStart) {
    -                putBounded(out, &idx, limit, '+');
    -            }
    -        } else if (isNeg) {
    -            putBounded(out, &idx, limit, '-');
    +    for (int32_t i = 0; i < zeroPad + intLen; i++) {
    +        if (f->hasComma && i > 0 && (zeroPad + intLen - i) % 3 == 0) {
    +            putBounded(fmtIntPart, &dstIdx, (int32_t)sizeof(fmtIntPart) - 1, ',');
             }
    -    } else {
    -        // PRINT USING: plusAtStart always shows a sign; otherwise a leading
    -        // '-' for negatives unless the minus is deferred to the end.
    -        if (f->plusAtStart) {
    -            putBounded(out, &idx, limit, isNeg ? '-' : '+');
    -        } else if (isNeg && !f->minusAtEnd) {
    -            putBounded(out, &idx, limit, '-');
    +
    +        putBounded(fmtIntPart, &dstIdx, (int32_t)sizeof(fmtIntPart) - 1, (i < zeroPad) ? '0' : intPart[i - zeroPad]);
    +    }
    +
    +    fmtIntPart[dstIdx] = '\0';
    +
    +    // Sign placement: a leading '+' position always shows the sign; a
    +    // trailing '+' or '-' position carries it at the end; otherwise a
    +    // negative value gets a floating leading '-'.
    +    char lead  = '\0';
    +    char trail = '\0';
    +
    +    if (f->plusAtStart) {
    +        lead = isNeg ? '-' : '+';
    +    } else if (f->plusAtEnd) {
    +        trail = isNeg ? '-' : '+';
    +    } else if (f->minusAtEnd) {
    +        trail = isNeg ? '-' : ' ';
    +    } else if (isNeg) {
    +        lead = '-';
    +    }
    +
    +    int32_t decLen  = (int32_t)strlen(decPart);
    +    int32_t bodyLen = (lead ? 1 : 0) + (f->dollarFloat ? 1 : 0) + dstIdx + (f->hasDecimal ? 1 + decimals : 0) + (trail ? 1 : 0);
    +    int32_t pad     = f->width - bodyLen;
    +
    +    if (pad < 0) {
    +        pad = 0;
    +
    +        if (f->markOverflow) {
    +            putBounded(out, &idx, limit, BAS_FORMAT_OVERFLOW_MARK);
             }
         }
     
    -    // Dollar sign (PRINT USING $$ only).
    +    for (int32_t i = 0; i < pad; i++) {
    +        putBounded(out, &idx, limit, f->asteriskFill ? '*' : ' ');
    +    }
    +
    +    if (lead) {
    +        putBounded(out, &idx, limit, lead);
    +    }
    +
         if (f->dollarFloat) {
             putBounded(out, &idx, limit, '$');
         }
     
    -    // Pad leading. PRINT USING fills the whole pad region with fillChar
    -    // (space or '*'). FORMAT$ (formatPad) fills the positions before the
    -    // number with '0' when a zero digit precedes (zeroPad) else ' ', and the
    -    // remaining positions with '0'.
    -    int32_t fmtIntLen = (int32_t)strlen(fmtIntPart);
    -    int32_t intRawLen = (int32_t)strlen(intPart);
    -    int32_t padNeeded = f->digitsBefore - fmtIntLen;
    -    char    fillChar  = f->asteriskFill ? '*' : ' ';
    -
    -    for (int32_t i = 0; i < padNeeded; i++) {
    -        char padChar;
    -
    -        if (f->formatPad) {
    -            if (i < padNeeded - intRawLen) {
    -                padChar = f->zeroPad ? '0' : ' ';
    -            } else {
    -                padChar = '0';
    -            }
    -        } else {
    -            padChar = fillChar;
    -        }
    -
    -        putBounded(out, &idx, limit, padChar);
    -    }
    -
    -    // Integer part.
         for (int32_t i = 0; fmtIntPart[i]; i++) {
             putBounded(out, &idx, limit, fmtIntPart[i]);
         }
     
    -    // Decimal part. Missing digits fill with '0' (PRINT USING behavior);
    -    // FORMAT$ always has exactly 'decimals' digits so this never triggers.
         // decimals comes from the runtime format string, so it can exceed the
         // digits snprintf produced into decPart -- never read past decLen.
         if (f->hasDecimal) {
    -        int32_t decLen = (int32_t)strlen(decPart);
    -
             putBounded(out, &idx, limit, '.');
     
             for (int32_t i = 0; i < decimals; i++) {
    @@ -5513,23 +5704,8 @@ static void formatNumber(double n, const BasNumFormatT *f, char *out, size_t out
             }
         }
     
    -    // Trailing sign. FORMAT$ already emitted the leading '-' for negatives,
    -    // so its trailing plusEnd path only shows '+' for positives. PRINT USING
    -    // defers the sign to the end, so its plusEnd path shows '-' for negatives.
    -    if (f->formatPad) {
    -        if (f->plusAtEnd && !isNeg) {
    -            putBounded(out, &idx, limit, '+');
    -        } else if (f->minusAtEnd && isNeg) {
    -            putBounded(out, &idx, limit, '-');
    -        } else if (f->minusAtEnd && !isNeg) {
    -            putBounded(out, &idx, limit, ' ');
    -        }
    -    } else {
    -        if (f->plusAtEnd) {
    -            putBounded(out, &idx, limit, isNeg ? '-' : '+');
    -        } else if (f->minusAtEnd) {
    -            putBounded(out, &idx, limit, isNeg ? '-' : ' ');
    -        }
    +    if (trail) {
    +        putBounded(out, &idx, limit, trail);
         }
     
         out[idx] = '\0';
    @@ -5560,6 +5736,113 @@ static void forStackTrim(BasVmT *vm, int32_t newDepth) {
     }
     
     
    +// GET of one numeric value in its binary form (INTEGER and BOOLEAN 2
    +// bytes, LONG 4, SINGLE 4, DOUBLE 8).  Shared by scalar GET and TYPE fields.
    +static BasValueT ioReadScalar(FILE *fp, uint8_t type) {
    +    switch (type) {
    +        case BAS_TYPE_LONG: {
    +            int32_t val = 0;
    +            readRaw(fp, &val, sizeof(val));
    +            return basValLong(val);
    +        }
    +
    +        case BAS_TYPE_SINGLE: {
    +            float val = 0.0f;
    +            readRaw(fp, &val, sizeof(val));
    +            return basValSingle(val);
    +        }
    +
    +        case BAS_TYPE_DOUBLE: {
    +            double val = 0.0;
    +            readRaw(fp, &val, sizeof(val));
    +            return basValDouble(val);
    +        }
    +
    +        case BAS_TYPE_BOOLEAN: {
    +            int16_t val = 0;
    +            readRaw(fp, &val, sizeof(val));
    +            return basValBool(val != 0);
    +        }
    +
    +        default: {
    +            int16_t val = 0;
    +            readRaw(fp, &val, sizeof(val));
    +            return basValInteger(val);
    +        }
    +    }
    +}
    +
    +
    +// PUT counterpart of ioReadScalar; val is converted to the layout type.
    +static void ioWriteScalar(FILE *fp, uint8_t type, BasValueT val) {
    +    switch (type) {
    +        case BAS_TYPE_LONG: {
    +            int32_t v = basValToInt32(val);
    +            fwrite(&v, sizeof(v), 1, fp);
    +            break;
    +        }
    +
    +        case BAS_TYPE_SINGLE: {
    +            float v = (float)basValToNumber(val);
    +            fwrite(&v, sizeof(v), 1, fp);
    +            break;
    +        }
    +
    +        case BAS_TYPE_DOUBLE: {
    +            double v = basValToNumber(val);
    +            fwrite(&v, sizeof(v), 1, fp);
    +            break;
    +        }
    +
    +        case BAS_TYPE_BOOLEAN: {
    +            int16_t v = basValIsTruthy(val) ? -1 : 0;
    +            fwrite(&v, sizeof(v), 1, fp);
    +            break;
    +        }
    +
    +        default: {
    +            int16_t v = (int16_t)basValToInt32(val);
    +            fwrite(&v, sizeof(v), 1, fp);
    +            break;
    +        }
    +    }
    +}
    +
    +
    +// Reads the next TYPE layout entry (see BAS_LAYOUT_* in opcodes.h).
    +// Returns false when the layout is exhausted or truncated.
    +static bool layoutNext(const BasStringT *layout, int32_t *pos, BasLayoutEntryT *out) {
    +    if (*pos >= layout->len) {
    +        return false;
    +    }
    +
    +    const uint8_t *d = (const uint8_t *)layout->data;
    +
    +    memset(out, 0, sizeof(*out));
    +    out->type = d[*pos];
    +    *pos     += 1;
    +
    +    if (out->type == BAS_TYPE_STRING) {
    +        if (*pos + 2 > layout->len) {
    +            return false;
    +        }
    +
    +        out->fixedLen = d[*pos] | (d[*pos + 1] << 8);
    +        *pos         += 2;
    +    } else if (out->type == BAS_TYPE_UDT) {
    +        if (*pos + 4 > layout->len) {
    +            return false;
    +        }
    +
    +        out->typeId     = d[*pos] | (d[*pos + 1] << 8);
    +        out->fieldCount = d[*pos + 2] | (d[*pos + 3] << 8);
    +        *pos           += 4;
    +    }
    +
    +    return true;
    +}
    +
    +
     // Local wall-clock time, with the sub-second part in microseconds when
     // outUsec is non-NULL.  Shared by TIMER, DATE$ and TIME$.
     static void localNow(struct tm *out, int32_t *outUsec) {
    @@ -5635,6 +5918,14 @@ static int32_t nextStatementPc(BasVmT *vm) {
     }
     
     
    +// Form/control opcode executed with no form runtime attached (headless
    +// runs such as basrun or the test harnesses).
    +static BasVmResultE noFormRuntime(BasVmT *vm) {
    +    runtimeError(vm, BAS_ERR_NO_FORM_RUNTIME, "No form runtime");
    +    return BAS_VM_ERROR;
    +}
    +
    +
     // Returns whether `size` bytes can be read at vm->pc without running past
     // the end of code[]. On overflow it flags the VM and clamps pc to codeLen so
     // the next basVmStep entry raises BAS_VM_BAD_OPCODE. Compared as
    @@ -5652,24 +5943,33 @@ static inline bool operandFits(BasVmT *vm, int32_t size) {
     
     
     // Scans a PRINT USING / FORMAT$ numeric format string into f.  Both
    -// statements share the placeholder grammar: '#', '0' (and '*' before the
    -// decimal point) are digit positions, '.' and ',' set the decimal and
    -// thousands flags, a leading '+' or a trailing '+'/'-' selects the sign
    -// style, leading "**" and "$$" select fills, and "^^^^" requests
    -// scientific notation (reported through outSci).
    +// statements share the placeholder grammar: '#' and '0' (and '*' before
    +// the decimal point) are digit positions, '0' also fixes the minimum
    +// number of integer digits, '.' and ',' set the decimal and thousands
    +// flags, a leading '+' or a trailing '+'/'-' selects the sign style,
    +// leading "**" and "$$" select fills, and "^^^^" requests scientific
    +// notation (reported through outSci).  The field width is the length of
    +// the format string.
     static void parseNumFormat(const char *fmt, int32_t fmtLen, BasNumFormatT *f, bool *outSci) {
         memset(f, 0, sizeof(*f));
     
    -    f->asteriskFill = (fmtLen >= 2 && fmt[0] == '*' && fmt[1] == '*');
    +    int32_t scanStart = (fmtLen > 0 && fmt[0] == '+') ? 1 : 0;
     
    -    int32_t scanStart = f->asteriskFill ? 2 : 0;
    +    f->plusAtStart  = (scanStart == 1);
    +    f->asteriskFill = (fmtLen >= scanStart + 2 && fmt[scanStart] == '*' && fmt[scanStart + 1] == '*');
    +
    +    if (f->asteriskFill) {
    +        scanStart += 2;
    +    }
     
         f->dollarFloat = (fmtLen >= scanStart + 2 && fmt[scanStart] == '$' && fmt[scanStart + 1] == '$');
    -    f->plusAtStart = (fmtLen > 0 && fmt[0] == '+');
    -    f->plusAtEnd   = (fmtLen > 0 && fmt[fmtLen - 1] == '+');
    -    f->minusAtEnd  = (fmtLen > 0 && fmt[fmtLen - 1] == '-');
    +    f->plusAtEnd   = (fmtLen > scanStart && fmt[fmtLen - 1] == '+');
    +    f->minusAtEnd  = (fmtLen > scanStart && fmt[fmtLen - 1] == '-');
    +    f->width       = fmtLen;
         *outSci        = (strstr(fmt, BAS_SCI_MARKER) != NULL);
     
    +    int32_t intDigits = 0;
    +
         for (int32_t i = 0; i < fmtLen; i++) {
             if (fmt[i] == '.') {
                 f->hasDecimal = true;
    @@ -5679,14 +5979,20 @@ static void parseNumFormat(const char *fmt, int32_t fmtLen, BasNumFormatT *f, bo
                 if (f->hasDecimal) {
                     f->digitsAfter++;
                 } else {
    -                f->digitsBefore++;
    -            }
    +                intDigits++;
     
    -            if (fmt[i] == '0' && !f->hasDecimal) {
    -                f->zeroPad = true;
    +                if (fmt[i] == '0' && f->minIntDigits == 0) {
    +                    // Every position from the first '0' to the decimal
    +                    // point is a forced digit.
    +                    f->minIntDigits = -intDigits;
    +                }
                 }
             }
         }
    +
    +    if (f->minIntDigits < 0) {
    +        f->minIntDigits = intDigits + f->minIntDigits + 1;
    +    }
     }
     
     
    @@ -5768,6 +6074,7 @@ static void popCallFrame(BasVmT *vm) {
         if (hadHandler) {
             vm->inErrorHandler = false;
             vm->errorNumber    = 0;
    +        vm->errorLine      = 0;
             vm->errorMsg[0]    = '\0';
         }
     }
    @@ -5860,6 +6167,23 @@ static void primeModuleFrame(BasVmT *vm) {
     
     // Pushes val, taking ownership of it: on stack overflow the value is
     // released here so no caller has to remember to.
    +// Every PRINT byte goes through here so the output column is known for
    +// comma zones and TAB().
    +static void printText(BasVmT *vm, const char *text, bool newline) {
    +    if (vm->printFn) {
    +        vm->printFn(vm->printCtx, text, newline);
    +    }
    +
    +    for (const char *c = text; *c; c++) {
    +        vm->printColumn = (*c == '\n') ? 0 : vm->printColumn + 1;
    +    }
    +
    +    if (newline) {
    +        vm->printColumn = 0;
    +    }
    +}
    +
    +
     static bool push(BasVmT *vm, BasValueT val) {
         if (vm->sp >= BAS_VM_STACK_SIZE) {
             basValRelease(&val);
    @@ -6070,6 +6394,47 @@ static inline uint8_t readUint8(BasVmT *vm) {
     }
     
     
    +// Positions a channel for GET/PUT (records for RANDOM, bytes for BINARY,
    +// both 1-based; 0 keeps the current position) and returns the start
    +// offset so recordEnd can frame the transfer.
    +static long recordBegin(BasVmT *vm, int32_t channel, int32_t recno) {
    +    seekRecord(vm, channel, recno);
    +    return ftell((FILE *)vm->files[channel].handle);
    +}
    +
    +
    +// Completes a GET/PUT.  RANDOM channels always transfer whole LEN-byte
    +// records: a PUT pads the record with zeros and a GET skips to the next
    +// record; data longer than LEN raises error 59.
    +static BasVmResultE recordEnd(BasVmT *vm, int32_t channel, long start, bool writing) {
    +    FILE *fp = (FILE *)vm->files[channel].handle;
    +
    +    if (vm->files[channel].mode == BAS_FILE_MODE_RANDOM) {
    +        long recLen = vm->files[channel].recLen;
    +        long used   = ftell(fp) - start;
    +
    +        if (used > recLen) {
    +            runtimeError(vm, BAS_ERR_BAD_RECORD_LEN, "Record longer than LEN");
    +            return BAS_VM_FILE_ERROR;
    +        }
    +
    +        if (writing) {
    +            for (long i = used; i < recLen; i++) {
    +                fputc(0, fp);
    +            }
    +        } else {
    +            fseek(fp, start + recLen, SEEK_SET);
    +        }
    +    }
    +
    +    if (writing) {
    +        fflush(fp);
    +    }
    +
    +    return BAS_VM_OK;
    +}
    +
    +
     // Releases every refcounted value the VM holds: the eval stack, the
     // globals, all call-frame locals across callDepth, and the FOR-stack
     // limit/step across forDepth.  Must run BEFORE the caller zeroes
    @@ -6252,6 +6617,22 @@ static void seekRecord(BasVmT *vm, int32_t channel, int32_t recno) {
     // Replaces *target with val, releasing the old value.  A TYPE value that is
     // still shared with another variable is deep-copied first so assignment has
     // value semantics (b = a must not alias a).
    +// Stores into an array element or TYPE field.  An array reference is
    +// refused there: the compiler never emits one (elements and fields are
    +// scalars or TYPE instances) and a corrupt module storing an array
    +// inside itself would build a reference cycle nothing ever frees.
    +static bool storeElement(BasVmT *vm, BasValueT *target, BasValueT val) {
    +    if (val.type == BAS_TYPE_ARRAY) {
    +        basValRelease(&val);
    +        runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Array reference cannot be stored in an element");
    +        return false;
    +    }
    +
    +    storeValue(target, val);
    +    return true;
    +}
    +
    +
     static void storeValue(BasValueT *target, BasValueT val) {
         basValRelease(target);
         *target = udtDetach(val);
    @@ -6304,6 +6685,386 @@ static BasValueT udtDetach(BasValueT val) {
     }
     
     
    +// Builds a TYPE instance from its layout: fixed strings start as spaces,
    +// variable strings empty, numerics as typed zeros, nested types are
    +// built recursively.  NULL on a malformed layout or out of memory.
    +static BasUdtT *udtFromLayout(BasVmT *vm, const BasStringT *layout, int32_t *pos, int32_t typeId, int32_t fieldCount, int32_t depth) {
    +    if (depth >= BAS_LAYOUT_MAX_DEPTH || fieldCount < 0) {
    +        runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Bad TYPE layout");
    +        return NULL;
    +    }
    +
    +    BasUdtT *udt = basUdtNew(typeId, fieldCount);
    +
    +    if (!udt) {
    +        runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of memory allocating TYPE");
    +        return NULL;
    +    }
    +
    +    for (int32_t i = 0; i < fieldCount; i++) {
    +        BasLayoutEntryT entry;
    +
    +        if (!layoutNext(layout, pos, &entry)) {
    +            basUdtUnref(udt);
    +            runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Bad TYPE layout");
    +            return NULL;
    +        }
    +
    +        if (entry.type == BAS_TYPE_UDT) {
    +            BasUdtT *child = udtFromLayout(vm, layout, pos, entry.typeId, entry.fieldCount, depth + 1);
    +
    +            if (!child) {
    +                basUdtUnref(udt);
    +                return NULL;
    +            }
    +
    +            udt->fields[i].type   = BAS_TYPE_UDT;
    +            udt->fields[i].udtVal = child;
    +        } else if (entry.type == BAS_TYPE_STRING && entry.fixedLen > 0) {
    +            udt->fields[i] = strValue(fillString(' ', entry.fixedLen));
    +        } else {
    +            udt->fields[i] = zeroValue(entry.type);
    +        }
    +    }
    +
    +    return udt;
    +}
    +
    +
    +// GET into a TYPE instance: walks the compiler's layout (one entry per
    +// field, nested types inline) and fills each field in place.
    +static BasVmResultE udtGet(BasVmT *vm, FILE *fp, BasUdtT *udt, const BasStringT *layout, int32_t *pos, int32_t depth) {
    +    if (depth >= BAS_LAYOUT_MAX_DEPTH) {
    +        runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "TYPE nesting too deep");
    +        return BAS_VM_TYPE_MISMATCH;
    +    }
    +
    +    for (int32_t i = 0; i < udt->fieldCount; i++) {
    +        BasLayoutEntryT entry;
    +
    +        if (!layoutNext(layout, pos, &entry)) {
    +            runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Bad TYPE layout");
    +            return BAS_VM_TYPE_MISMATCH;
    +        }
    +
    +        uint8_t    type     = entry.type;
    +        int32_t    fixedLen = entry.fixedLen;
    +        BasValueT *field    = &udt->fields[i];
    +
    +        if (type == BAS_TYPE_UDT) {
    +            if (field->type != BAS_TYPE_UDT || !field->udtVal) {
    +                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not a TYPE instance");
    +                return BAS_VM_TYPE_MISMATCH;
    +            }
    +
    +            BasVmResultE rc = udtGet(vm, fp, field->udtVal, layout, pos, depth + 1);
    +
    +            if (rc != BAS_VM_OK) {
    +                return rc;
    +            }
    +
    +            continue;
    +        }
    +
    +        if (type == BAS_TYPE_STRING) {
    +            int32_t len = fixedLen;
    +
    +            if (len == 0) {
    +                int16_t prefix = 0;
    +                readRaw(fp, &prefix, sizeof(prefix));
    +                len = (prefix < 0) ? 0 : prefix;
    +            }
    +
    +            char *buf = (char *)malloc(len + 1);
    +
    +            if (!buf) {
    +                runtimeError(vm, BAS_ERR_OUT_OF_MEMORY, "Out of memory");
    +                return BAS_VM_OUT_OF_MEMORY;
    +            }
    +
    +            int32_t bytesRead = (int32_t)fread(buf, 1, len, fp);
    +
    +            // A fixed field always holds exactly its declared length.
    +            for (int32_t k = bytesRead; k < fixedLen; k++) {
    +                buf[k] = ' ';
    +            }
    +
    +            storeValue(field, strValue(basStringNew(buf, fixedLen ? fixedLen : bytesRead)));
    +            free(buf);
    +            continue;
    +        }
    +
    +        storeValue(field, ioReadScalar(fp, type));
    +    }
    +
    +    return BAS_VM_OK;
    +}
    +
    +
    +// PUT counterpart of udtGet.
    +static BasVmResultE udtPut(BasVmT *vm, FILE *fp, const BasUdtT *udt, const BasStringT *layout, int32_t *pos, int32_t depth) {
    +    if (depth >= BAS_LAYOUT_MAX_DEPTH) {
    +        runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "TYPE nesting too deep");
    +        return BAS_VM_TYPE_MISMATCH;
    +    }
    +
    +    for (int32_t i = 0; i < udt->fieldCount; i++) {
    +        BasLayoutEntryT entry;
    +
    +        if (!layoutNext(layout, pos, &entry)) {
    +            runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Bad TYPE layout");
    +            return BAS_VM_TYPE_MISMATCH;
    +        }
    +
    +        uint8_t   type     = entry.type;
    +        int32_t   fixedLen = entry.fixedLen;
    +        BasValueT field    = udt->fields[i];
    +
    +        if (type == BAS_TYPE_UDT) {
    +            if (field.type != BAS_TYPE_UDT || !field.udtVal) {
    +                runtimeError(vm, BAS_ERR_TYPE_MISMATCH, "Not a TYPE instance");
    +                return BAS_VM_TYPE_MISMATCH;
    +            }
    +
    +            BasVmResultE rc = udtPut(vm, fp, field.udtVal, layout, pos, depth + 1);
    +
    +            if (rc != BAS_VM_OK) {
    +                return rc;
    +            }
    +
    +            continue;
    +        }
    +
    +        if (type == BAS_TYPE_STRING) {
    +            BasValueT sv   = basValToString(field);
    +            int32_t   slen = sv.strVal->len;
    +
    +            if (fixedLen > 0) {
    +                int32_t copyLen = slen < fixedLen ? slen : fixedLen;
    +
    +                fwrite(sv.strVal->data, 1, copyLen, fp);
    +
    +                for (int32_t k = copyLen; k < fixedLen; k++) {
    +                    fputc(' ', fp);
    +                }
    +            } else {
    +                if (slen > INT16_MAX) {
    +                    slen = INT16_MAX;
    +                }
    +
    +                int16_t len = (int16_t)slen;
    +                fwrite(&len, sizeof(len), 1, fp);
    +
    +                if (len > 0) {
    +                    fwrite(sv.strVal->data, 1, len, fp);
    +                }
    +            }
    +
    +            basValRelease(&sv);
    +            continue;
    +        }
    +
    +        ioWriteScalar(fp, type, field);
    +    }
    +
    +    return BAS_VM_OK;
    +}
    +
    +
    +// Length of the PRINT USING field starting at fmt[pos], or 0 when the
    +// character there is literal text.  String fields are '!', '&' and a
    +// backslash pair; numeric fields start with '#', '.', "**", "$$" or a
    +// '+' followed by one of those, run over the digit, comma and point
    +// positions, and may end in "^^^^" and a trailing sign.
    +static int32_t usingFieldLen(const char *fmt, int32_t fmtLen, int32_t pos) {
    +    char c = fmt[pos];
    +
    +    if (c == '!' || c == '&') {
    +        return 1;
    +    }
    +
    +    if (c == '\\') {
    +        for (int32_t i = pos + 1; i < fmtLen; i++) {
    +            if (fmt[i] == '\\') {
    +                return i - pos + 1;
    +            }
    +        }
    +
    +        return 0;
    +    }
    +
    +    int32_t i        = pos;
    +    bool    hasFill  = false;
    +    bool    leadPlus = (c == '+');
    +
    +    if (leadPlus) {
    +        i++;
    +    }
    +
    +    if (i + 1 < fmtLen && fmt[i] == '*' && fmt[i + 1] == '*') {
    +        hasFill = true;
    +        i      += 2;
    +    }
    +
    +    if (i + 1 < fmtLen && fmt[i] == '$' && fmt[i + 1] == '$') {
    +        hasFill = true;
    +        i      += 2;
    +    }
    +
    +    if (!hasFill && i < fmtLen && fmt[i] != '#' && !(fmt[i] == '.' && i + 1 < fmtLen && fmt[i + 1] == '#')) {
    +        return 0;
    +    }
    +
    +    while (i < fmtLen && (fmt[i] == '#' || fmt[i] == '0' || fmt[i] == ',' || fmt[i] == '.')) {
    +        i++;
    +    }
    +
    +    if (i + (int32_t)strlen(BAS_SCI_MARKER) <= fmtLen && strncmp(fmt + i, BAS_SCI_MARKER, strlen(BAS_SCI_MARKER)) == 0) {
    +        i += (int32_t)strlen(BAS_SCI_MARKER);
    +    }
    +
    +    if (!leadPlus && i < fmtLen && (fmt[i] == '+' || fmt[i] == '-')) {
    +        i++;
    +    }
    +
    +    return i - pos;
    +}
    +
    +
    +// Formats one PRINT USING value: emits the literal text from the current
    +// scan position up to the next field, the field rendered from val, and,
    +// for the last value of the statement, the literal text after it.  The
    +// scan wraps to the start of the format when it runs out of fields.
    +// Returns NULL when the field type does not match the value.
    +static BasStringT *usingFormat(BasVmT *vm, const BasStringT *fmt, BasValueT val, bool last) {
    +    int32_t valLen = (val.type == BAS_TYPE_STRING && val.strVal) ? val.strVal->len : 0;
    +    int32_t cap    = fmt->len * 2 + valLen + BAS_FORMAT_BUF_SIZE + 1;
    +    char   *buf    = (char *)malloc(cap);
    +
    +    if (!buf) {
    +        return basStringRef(basEmptyString);
    +    }
    +
    +    int32_t idx      = 0;
    +    int32_t limit    = cap - 1;
    +    int32_t fieldLen = 0;
    +    bool    wrapped  = false;
    +
    +    // Literal text up to the next field; wrap at most once.
    +    while (vm->usingPos < fmt->len || !wrapped) {
    +        if (vm->usingPos >= fmt->len) {
    +            wrapped      = true;
    +            vm->usingPos = 0;
    +
    +            if (fmt->len == 0) {
    +                break;
    +            }
    +        }
    +
    +        char c = fmt->data[vm->usingPos];
    +
    +        if (c == BAS_USING_ESCAPE && vm->usingPos + 1 < fmt->len) {
    +            putBounded(buf, &idx, limit, fmt->data[vm->usingPos + 1]);
    +            vm->usingPos += 2;
    +            continue;
    +        }
    +
    +        fieldLen = usingFieldLen(fmt->data, fmt->len, vm->usingPos);
    +
    +        if (fieldLen > 0) {
    +            break;
    +        }
    +
    +        putBounded(buf, &idx, limit, c);
    +        vm->usingPos++;
    +    }
    +
    +    if (fieldLen == 0) {
    +        // No field anywhere in the format: the value prints as-is.
    +        BasStringT *plain = basValFormatString(val);
    +
    +        for (int32_t i = 0; i < plain->len; i++) {
    +            putBounded(buf, &idx, limit, plain->data[i]);
    +        }
    +
    +        basStringUnref(plain);
    +        buf[idx] = '\0';
    +
    +        BasStringT *result = basStringNew(buf, idx);
    +        free(buf);
    +        return result;
    +    }
    +
    +    const char *field    = fmt->data + vm->usingPos;
    +    bool        strField = (field[0] == '!' || field[0] == '&' || field[0] == '\\');
    +
    +    vm->usingPos += fieldLen;
    +
    +    if (strField != (val.type == BAS_TYPE_STRING)) {
    +        free(buf);
    +        return NULL;
    +    }
    +
    +    if (strField) {
    +        const char *src    = val.strVal ? val.strVal->data : "";
    +        int32_t     srcLen = val.strVal ? val.strVal->len : 0;
    +        int32_t     width  = (field[0] == '!') ? 1 : (field[0] == '&') ? srcLen : fieldLen;
    +
    +        for (int32_t i = 0; i < width; i++) {
    +            putBounded(buf, &idx, limit, (i < srcLen) ? src[i] : ' ');
    +        }
    +    } else {
    +        double        n = basValToNumber(val);
    +        BasNumFormatT f;
    +        bool          sciNotation;
    +        char          numBuf[BAS_FORMAT_BUF_SIZE];
    +
    +        parseNumFormat(field, fieldLen, &f, &sciNotation);
    +        f.markOverflow = true;
    +
    +        if (sciNotation) {
    +            char    sciFmt[BAS_SCI_FMT_LEN];
    +            int32_t decimals = f.hasDecimal ? f.digitsAfter : 0;
    +
    +            snprintf(sciFmt, sizeof(sciFmt), "%%%d.%dE", (int)fieldLen, (int)decimals);
    +            snprintf(numBuf, sizeof(numBuf), sciFmt, n);
    +        } else {
    +            formatNumber(n, &f, numBuf, sizeof(numBuf));
    +        }
    +
    +        for (int32_t i = 0; numBuf[i]; i++) {
    +            putBounded(buf, &idx, limit, numBuf[i]);
    +        }
    +    }
    +
    +    // Trailing literal text after the last value, up to the next field.
    +    if (last) {
    +        while (vm->usingPos < fmt->len) {
    +            char c = fmt->data[vm->usingPos];
    +
    +            if (c == BAS_USING_ESCAPE && vm->usingPos + 1 < fmt->len) {
    +                putBounded(buf, &idx, limit, fmt->data[vm->usingPos + 1]);
    +                vm->usingPos += 2;
    +                continue;
    +            }
    +
    +            if (usingFieldLen(fmt->data, fmt->len, vm->usingPos) > 0) {
    +                break;
    +            }
    +
    +            putBounded(buf, &idx, limit, c);
    +            vm->usingPos++;
    +        }
    +    }
    +
    +    buf[idx] = '\0';
    +
    +    BasStringT *result = basStringNew(buf, idx);
    +    free(buf);
    +    return result;
    +}
    +
    +
     static bool validArrayDims(BasVmT *vm, int32_t dims) {
         if (dims < 1 || dims > BAS_ARRAY_MAX_DIMS) {
             runtimeError(vm, BAS_ERR_SUBSCRIPT_RANGE, "Invalid array dimension count");
    @@ -6312,3 +7073,28 @@ static bool validArrayDims(BasVmT *vm, int32_t dims) {
     
         return true;
     }
    +
    +
    +// The declared zero of a data type: "" for strings, False for booleans,
    +// a typed numeric zero otherwise.
    +static BasValueT zeroValue(uint8_t type) {
    +    switch (type) {
    +        case BAS_TYPE_STRING:
    +            return strValue(basStringRef(basEmptyString));
    +
    +        case BAS_TYPE_BOOLEAN:
    +            return basValBool(false);
    +
    +        case BAS_TYPE_LONG:
    +            return basValLong(0);
    +
    +        case BAS_TYPE_SINGLE:
    +            return basValSingle(0.0f);
    +
    +        case BAS_TYPE_DOUBLE:
    +            return basValDouble(0.0);
    +
    +        default:
    +            return basValInteger(0);
    +    }
    +}
    diff --git a/src/apps/kpunch/dvxbasic/runtime/vm.h b/src/apps/kpunch/dvxbasic/runtime/vm.h
    index c372a73..442864a 100644
    --- a/src/apps/kpunch/dvxbasic/runtime/vm.h
    +++ b/src/apps/kpunch/dvxbasic/runtime/vm.h
    @@ -441,6 +441,11 @@ typedef struct {
         // String comparison mode
         bool     compareTextMode;                        // true = case-insensitive comparisons
     
    +    // PRINT state: output column (0-based, for comma zones and TAB) and
    +    // the scan position inside the active PRINT USING format string.
    +    int32_t  printColumn;
    +    int32_t  usingPos;
    +
         // Error handling.  The active ON ERROR handler lives on the call frame
         // that installed it (BasCallFrameT.errorHandler); the dispatcher walks
         // frames to find it.
    @@ -513,13 +518,13 @@ BasVmResultE basVmRun(BasVmT *vm);
     BasVmResultE basVmStep(BasVmT *vm);
     
     // Set I/O callbacks.
    -void basVmSetPrintCallback(BasVmT *vm, BasPrintFnT fn, void *ctx);
    -void basVmSetInputCallback(BasVmT *vm, BasInputFnT fn, void *ctx);
     void basVmSetDoEventsCallback(BasVmT *vm, BasDoEventsFnT fn, void *ctx);
    +void basVmSetInputCallback(BasVmT *vm, BasInputFnT fn, void *ctx);
    +void basVmSetPrintCallback(BasVmT *vm, BasPrintFnT fn, void *ctx);
     
     // Set UI callbacks (for form/control system).
    -void basVmSetUiCallbacks(BasVmT *vm, const BasUiCallbacksT *ui);
     void basVmSetSqlCallbacks(BasVmT *vm, const BasSqlCallbacksT *sql);
    +void basVmSetUiCallbacks(BasVmT *vm, const BasUiCallbacksT *ui);
     
     // Set external library callbacks (for DECLARE LIBRARY support).
     void basVmSetExternCallbacks(BasVmT *vm, const BasExternCallbacksT *ext);
    diff --git a/src/apps/kpunch/dvxbasic/stub/bascomp.c b/src/apps/kpunch/dvxbasic/stub/bascomp.c
    index 75f9dca..0be447e 100644
    --- a/src/apps/kpunch/dvxbasic/stub/bascomp.c
    +++ b/src/apps/kpunch/dvxbasic/stub/bascomp.c
    @@ -48,18 +48,12 @@
     #include 
     #include 
     
    -// Initial capacity of the source-concatenation buffer, and the hard
    -// ceiling its capacity may reach.  The ceiling keeps the int32 capacity
    -// doubling in concatGrow from overflowing.
    -#define CONCAT_INITIAL_CAP   8192
    -#define CONCAT_MAX_CAP       0x40000000 // 1 GiB cap; keeps int32 doubling safe
     
     // Kernel view of the running image on hosted OSes.
     #define SELF_EXE_PATH        "/proc/self/exe"
     
     // Function prototypes (alphabetical)
     static void buildLog(const char *msg);
    -static bool concatGrow(char **buf, int32_t *cap, int32_t need);
     static const char *selfPath(const char *argv0);
     static void usage(void);
     int main(int argc, char **argv);
    @@ -70,35 +64,6 @@ static void buildLog(const char *msg) {
     }
     
     
    -static bool concatGrow(char **buf, int32_t *cap, int32_t need) {
    -    if (need < 0 || need > CONCAT_MAX_CAP) {
    -        return false;
    -    }
    -
    -    int32_t newCap = *cap;
    -
    -    while (newCap < need) {
    -        if (newCap > CONCAT_MAX_CAP / 2) {
    -            // Doubling would overflow int32; jump straight to need.
    -            newCap = need;
    -            break;
    -        }
    -
    -        newCap *= 2;
    -    }
    -
    -    char *grown = (char *)realloc(*buf, (size_t)newCap);
    -
    -    if (grown == NULL) {
    -        return false;
    -    }
    -
    -    *buf = grown;
    -    *cap = newCap;
    -    return true;
    -}
    -
    -
     // Path of this executable, which carries the embedded STUB and NOICON
     // resources.  On a hosted OS argv[0] is whatever the shell typed (a bare
     // name when invoked through PATH), so the kernel's view of the running
    @@ -153,7 +118,18 @@ int main(int argc, char **argv) {
             return 1;
         }
     
    -    // Load the project file
    +    // Load the project file.  prefsLoad treats a missing file as an
    +    // empty INI, so probe it first or a bad path would surface as a
    +    // confusing "no modules" build error.
    +    FILE *dbpProbe = fopen(dbpPath, "rb");
    +
    +    if (!dbpProbe) {
    +        fprintf(stderr, "Error: cannot open project file: %s\n", dbpPath);
    +        return 1;
    +    }
    +
    +    fclose(dbpProbe);
    +
         PrefsHandleT *prefs = prefsLoad(dbpPath);
     
         if (!prefs) {
    @@ -182,7 +158,6 @@ int main(int argc, char **argv) {
         const char *description = prefsGetString(prefs, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_DESCRIPTION, "");
         const char *iconPath    = prefsGetString(prefs, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_ICON, "");
         const char *helpFile    = prefsGetString(prefs, BAS_INI_SECTION_PROJECT, BAS_INI_KEY_HELPFILE, "");
    -    bool optionExplicit = prefsGetBool(prefs, BAS_INI_SECTION_SETTINGS, BAS_INI_KEY_OPTIONEXPLICIT, false);
     
         // Derive output path
         char outBuf[DVX_MAX_PATH];
    @@ -195,135 +170,17 @@ int main(int argc, char **argv) {
         printf("Project: %s\n", projName);
         printf("Output:  %s (%s)\n", outputPath, release ? "release" : "debug");
     
    -    // Collect source files (dynamic array; grows as needed)
    -    typedef struct {
    -        char path[DVX_MAX_PATH];
    -        bool isForm;
    -    } SrcFileT;
    +    // Concatenate the project's sources (modules first, then form code)
    +    BasProjectSourceT project;
     
    -    SrcFileT *files = NULL;
    -
    -    // Modules
    -    for (int32_t i = 0; ; i++) {
    -        char key[16];
    -        snprintf(key, sizeof(key), "File%d", (int)i);
    -        const char *val = prefsGetString(prefs, BAS_INI_SECTION_MODULES, key, NULL);
    -
    -        if (!val) {
    -            break;
    -        }
    -
    -        SrcFileT entry;
    -        snprintf(entry.path, DVX_MAX_PATH, "%s/%s", projectDir, val);
    -        entry.isForm = false;
    -        arrput(files, entry);
    +    if (!basBuildConcatProject(prefs, projectDir, &project)) {
    +        fprintf(stderr, "Error: %s\n", project.error);
    +        basBuildFreeProject(&project);
    +        prefsClose(prefs);
    +        return 1;
         }
     
    -    // Forms
    -    for (int32_t i = 0; ; i++) {
    -        char key[16];
    -        snprintf(key, sizeof(key), "File%d", (int)i);
    -        const char *val = prefsGetString(prefs, BAS_INI_SECTION_FORMS, key, NULL);
    -
    -        if (!val) {
    -            break;
    -        }
    -
    -        SrcFileT entry;
    -        snprintf(entry.path, DVX_MAX_PATH, "%s/%s", projectDir, val);
    -        entry.isForm = true;
    -        arrput(files, entry);
    -    }
    -
    -    int32_t fileCount = (int32_t)arrlen(files);
    -
    -    if (fileCount == 0) {
    -        fprintf(stderr, "Error: project has no source files.\n");
    -        goto failFiles;
    -    }
    -
    -    // Concatenate sources (modules first, then form code)
    -    int32_t concatCap = CONCAT_INITIAL_CAP;
    -    char   *concatBuf = (char *)malloc(concatCap);
    -
    -    if (!concatBuf) {
    -        fprintf(stderr, "Error: out of memory.\n");
    -        goto failFiles;
    -    }
    -
    -    int32_t pos = 0;
    -
    -    // Pass 0: .bas modules, Pass 1: .frm code sections
    -    for (int32_t pass = 0; pass < 2; pass++) {
    -        for (int32_t i = 0; i < fileCount; i++) {
    -            if (files[i].isForm != (pass == 1)) {
    -                continue;
    -            }
    -
    -            int32_t srcLen = 0;
    -            char *srcBuf = platformReadFile(files[i].path, &srcLen);
    -
    -            if (!srcBuf) {
    -                fprintf(stderr, "Error: cannot read %s\n", files[i].path);
    -                goto concatFail;
    -            }
    -
    -            const char *code = srcBuf;
    -
    -            if (files[i].isForm) {
    -                // Extract form name from "Begin Form "
    -                char formName[BAS_MAX_IDENT] = "";
    -                basExtractFormName(srcBuf, formName, BAS_MAX_IDENT);
    -
    -                // The BASIC code section follows the outer Begin Form block.
    -                code = srcBuf + basFindFormEndPos(srcBuf, srcLen);
    -
    -                int32_t codeLen = (int32_t)strlen(code);
    -
    -                if (!concatGrow(&concatBuf, &concatCap, pos + codeLen + 128)) {
    -                    fprintf(stderr, "Error: out of memory.\n");
    -                    free(srcBuf);
    -                    goto concatFail;
    -                }
    -
    -                // Inject BEGINFORM directive before form code
    -                if (formName[0]) {
    -                    pos += snprintf(concatBuf + pos, concatCap - pos, "BEGINFORM \"%s\"\n", formName);
    -                }
    -
    -                memcpy(concatBuf + pos, code, codeLen);
    -                pos += codeLen;
    -
    -                if (pos > 0 && concatBuf[pos - 1] != '\n') {
    -                    concatBuf[pos++] = '\n';
    -                }
    -
    -                // Inject ENDFORM directive after form code
    -                if (formName[0]) {
    -                    pos += snprintf(concatBuf + pos, concatCap - pos, "ENDFORM\n");
    -                }
    -            } else {
    -                int32_t codeLen = (int32_t)strlen(code);
    -
    -                if (!concatGrow(&concatBuf, &concatCap, pos + codeLen + 2)) {
    -                    fprintf(stderr, "Error: out of memory.\n");
    -                    free(srcBuf);
    -                    goto concatFail;
    -                }
    -
    -                memcpy(concatBuf + pos, code, codeLen);
    -                pos += codeLen;
    -
    -                if (pos > 0 && concatBuf[pos - 1] != '\n') {
    -                    concatBuf[pos++] = '\n';
    -                }
    -            }
    -
    -            free(srcBuf);
    -        }
    -    }
    -
    -    concatBuf[pos] = '\0';
    +    int32_t fileCount = project.fileCount;
     
         // Compile
         printf("Compiling %d file(s)...\n", (int)fileCount);
    @@ -332,28 +189,26 @@ int main(int argc, char **argv) {
     
         if (!parser) {
             fprintf(stderr, "Error: out of memory.\n");
    -        goto concatFail;
    +        goto failProject;
         }
     
    -    basParserInit(parser, concatBuf, pos);
    -    parser->optionExplicit = optionExplicit;
    +    basParserInit(parser, project.source, project.sourceLen);
    +    parser->optionExplicit = project.optionExplicit;
     
         if (!basParse(parser)) {
             fprintf(stderr, "Compile error at line %d: %s\n", (int)parser->errorLine, parser->error);
             basParserFree(parser);
             free(parser);
    -        goto concatFail;
    +        goto failProject;
         }
     
    -    free(concatBuf);
    -
         BasModuleT *mod = basParserBuildModule(parser);
         basParserFree(parser);
         free(parser);
     
         if (!mod) {
             fprintf(stderr, "Error: failed to build module.\n");
    -        goto failFiles;
    +        goto failProject;
         }
     
         printf("  code: %d bytes, %d procs, %d constants\n", (int)mod->codeLen, (int)mod->procCount, (int)mod->constCount);
    @@ -362,12 +217,8 @@ int main(int argc, char **argv) {
         // release builds, obfuscates them.
         char **frmSources = NULL; // stb_ds: owned form text
     
    -    for (int32_t i = 0; i < fileCount; i++) {
    -        if (!files[i].isForm) {
    -            continue;
    -        }
    -
    -        char *fdata = platformReadFile(files[i].path, NULL);
    +    for (int32_t i = 0; i < (int32_t)arrlen(project.frmPaths); i++) {
    +        char *fdata = platformReadFile(project.frmPaths[i], NULL);
     
             if (fdata) {
                 arrput(frmSources, fdata);
    @@ -400,7 +251,7 @@ int main(int argc, char **argv) {
     
         arrfree(frmSources);
         basModuleFree(mod);
    -    arrfree(files);
    +    basBuildFreeProject(&project);
         prefsClose(prefs);
     
         if (failure) {
    @@ -427,18 +278,10 @@ int main(int argc, char **argv) {
         printf("Created %s (%d bytes)\n", outputPath, (int)outBytes);
         return 0;
     
    -    // ----- Error cleanup paths -----
    -    // failFiles: only the files array exists.  Each early error frees its
    -    // own raw malloc'd buffers (concatBuf, parser) before jumping here.
    -failFiles:
    -    arrfree(files);
    +    // ----- Error cleanup path -----
    +    // The caller has already printed an appropriate error message.
    +failProject:
    +    basBuildFreeProject(&project);
         prefsClose(prefs);
         return 1;
    -
    -    // The concat buffer is owned by paths that run before the frm arrays
    -    // exist; free it here and route to the files-only cleanup.  The caller
    -    // has already printed an appropriate error message.
    -concatFail:
    -    free(concatBuf);
    -    goto failFiles;
     }
    diff --git a/src/apps/kpunch/dvxbasic/stub/basrun.c b/src/apps/kpunch/dvxbasic/stub/basrun.c
    new file mode 100644
    index 0000000..54a546c
    --- /dev/null
    +++ b/src/apps/kpunch/dvxbasic/stub/basrun.c
    @@ -0,0 +1,400 @@
    +// 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.
    +
    +// basrun.c -- DVX BASIC headless host runner
    +//
    +// Runs a BASIC program without the DVX shell or form runtime so the
    +// compiler and VM can be exercised end-to-end from a test script.
    +//
    +//   basrun [options] program.app | program.bas | project.dbp
    +//
    +// A .app is opened through dvxResOpen and its MODULE (and optional
    +// DEBUG) resource is deserialized exactly as basstub.c does.  A .bas
    +// or .dbp is compiled on the fly (a .dbp is concatenated by the shared
    +// basBuildConcatProject: modules first, then the code section of each
    +// form wrapped in BEGINFORM/ENDFORM).
    +//
    +// PRINT goes to stdout, INPUT reads a line from stdin.  No UI, SQL or
    +// extern callbacks are installed, so form/control opcodes are no-ops
    +// and DECLARE LIBRARY calls raise error 453.
    +//
    +// Options:
    +//   --steps N     abort after N bytecode steps (0 = unlimited)
    +//   --data DIR    directory for App.Config / App.Data (default: program dir)
    +//   --compact     .bas/.dbp only: strip + compact the module before running
    +//   --quiet       do not report runtime errors on stderr
    +//
    +// Exit code: 0 on normal completion, the BASIC error number on a
    +// runtime error (capped at BASRUN_EXIT_ERR_MAX), BASRUN_EXIT_STEPS when
    +// the step cap is hit, BASRUN_EXIT_USAGE for bad arguments or a program
    +// that could not be loaded or compiled.
    +
    +#include "../compiler/compact.h"
    +#include "../compiler/parser.h"
    +#include "../compiler/strip.h"
    +#include "../runtime/vm.h"
    +#include "../runtime/serialize.h"
    +#include "../basBuild.h"
    +#include "../basRes.h"
    +#include "dvxPrefs.h"
    +#include "dvxRes.h"
    +#include "dvxTypes.h"
    +#include "dvxPlat.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define BASRUN_EXIT_OK         0
    +#define BASRUN_EXIT_ERR_MAX    250   // runtime error numbers above this are clamped
    +#define BASRUN_EXIT_STEPS      253   // --steps cap reached
    +#define BASRUN_EXIT_USAGE      255   // bad arguments, unreadable or uncompilable program
    +
    +#define BASRUN_STEP_SLICE      BAS_VM_DEFAULT_STEP_SLICE
    +#define BASRUN_INPUT_LINE_LEN  1024
    +typedef struct {
    +    int32_t steps;
    +    bool    compact;
    +    bool    quiet;
    +    const char *dataDir;
    +    const char *program;
    +} OptionsT;
    +
    +
    +// Function prototypes (alphabetical; main last)
    +static BasModuleT  *compileSource(const char *source, int32_t len, bool optionExplicit, const char *label);
    +static bool         hostDoEvents(void *ctx);
    +static bool         hostInput(void *ctx, const char *prompt, char *buf, int32_t bufSize);
    +static void         hostPrint(void *ctx, const char *text, bool newline);
    +static BasModuleT  *loadApp(const char *path);
    +static BasModuleT  *loadBas(const char *path);
    +static BasModuleT  *loadDbp(const char *path);
    +static bool         parseArgs(int argc, char **argv, OptionsT *opt);
    +static void         programDir(const char *path, char *out, int32_t outSize);
    +static int32_t      runModule(BasModuleT *mod, const OptionsT *opt);
    +static void         usage(void);
    +int main(int argc, char **argv);
    +
    +
    +static BasModuleT *compileSource(const char *source, int32_t len, bool optionExplicit, const char *label) {
    +    BasParserT *parser = (BasParserT *)malloc(sizeof(BasParserT));
    +
    +    if (!parser) {
    +        fprintf(stderr, "basrun: out of memory\n");
    +        return NULL;
    +    }
    +
    +    basParserInit(parser, source, len);
    +    parser->optionExplicit = optionExplicit;
    +
    +    if (!basParse(parser)) {
    +        fprintf(stderr, "basrun: %s: compile error at line %d: %s\n", label, (int)parser->errorLine, parser->error);
    +        basParserFree(parser);
    +        free(parser);
    +        return NULL;
    +    }
    +
    +    BasModuleT *mod = basParserBuildModule(parser);
    +    basParserFree(parser);
    +    free(parser);
    +
    +    if (!mod) {
    +        fprintf(stderr, "basrun: %s: failed to build module\n", label);
    +    }
    +
    +    return mod;
    +}
    +
    +
    +static bool hostDoEvents(void *ctx) {
    +    (void)ctx;
    +    return true;
    +}
    +
    +
    +static bool hostInput(void *ctx, const char *prompt, char *buf, int32_t bufSize) {
    +    (void)ctx;
    +    (void)prompt;
    +    fflush(stdout);
    +
    +    if (!fgets(buf, bufSize, stdin)) {
    +        buf[0] = '\0';
    +        return false;
    +    }
    +
    +    // Drop the line terminator; platformStripLineEndings only removes CRs.
    +    int32_t len = (int32_t)strlen(buf);
    +
    +    while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) {
    +        len--;
    +    }
    +
    +    buf[len] = '\0';
    +    return true;
    +}
    +
    +
    +static void hostPrint(void *ctx, const char *text, bool newline) {
    +    (void)ctx;
    +    fputs(text, stdout);
    +
    +    if (newline) {
    +        fputc('\n', stdout);
    +    }
    +}
    +
    +
    +static BasModuleT *loadApp(const char *path) {
    +    DvxResHandleT *res = dvxResOpen(path);
    +
    +    if (!res) {
    +        fprintf(stderr, "basrun: %s: no resource directory\n", path);
    +        return NULL;
    +    }
    +
    +    uint32_t modSize = 0;
    +    uint8_t *modData = (uint8_t *)dvxResRead(res, BAS_RES_MODULE, &modSize);
    +
    +    if (!modData) {
    +        fprintf(stderr, "basrun: %s: MODULE resource not found\n", path);
    +        dvxResClose(res);
    +        return NULL;
    +    }
    +
    +    BasModuleT *mod = basModuleDeserialize(modData, (int32_t)modSize);
    +    free(modData);
    +
    +    if (!mod) {
    +        fprintf(stderr, "basrun: %s: failed to deserialize module\n", path);
    +        dvxResClose(res);
    +        return NULL;
    +    }
    +
    +    uint32_t dbgSize = 0;
    +    uint8_t *dbgData = (uint8_t *)dvxResRead(res, BAS_RES_DEBUG, &dbgSize);
    +
    +    if (dbgData) {
    +        basDebugDeserialize(mod, dbgData, (int32_t)dbgSize);
    +        free(dbgData);
    +    }
    +
    +    dvxResClose(res);
    +    return mod;
    +}
    +
    +
    +static BasModuleT *loadBas(const char *path) {
    +    int32_t len = 0;
    +    char   *src = platformReadFile(path, &len);
    +
    +    if (!src) {
    +        fprintf(stderr, "basrun: cannot read %s\n", path);
    +        return NULL;
    +    }
    +
    +    BasModuleT *mod = compileSource(src, len, false, path);
    +    free(src);
    +    return mod;
    +}
    +
    +
    +static BasModuleT *loadDbp(const char *path) {
    +    // prefsLoad opens a missing file as an empty INI; probe first so a
    +    // bad path is reported as such.
    +    FILE *probe = fopen(path, "rb");
    +
    +    if (!probe) {
    +        fprintf(stderr, "basrun: cannot open project %s\n", path);
    +        return NULL;
    +    }
    +
    +    fclose(probe);
    +
    +    PrefsHandleT *prefs = prefsLoad(path);
    +
    +    if (!prefs) {
    +        fprintf(stderr, "basrun: cannot open project %s\n", path);
    +        return NULL;
    +    }
    +
    +    char projectDir[DVX_MAX_PATH];
    +    programDir(path, projectDir, sizeof(projectDir));
    +
    +    BasProjectSourceT project;
    +    BasModuleT       *mod = NULL;
    +
    +    if (basBuildConcatProject(prefs, projectDir, &project)) {
    +        mod = compileSource(project.source, project.sourceLen, project.optionExplicit, path);
    +    } else {
    +        fprintf(stderr, "basrun: %s: %s\n", path, project.error);
    +    }
    +
    +    basBuildFreeProject(&project);
    +    prefsClose(prefs);
    +    return mod;
    +}
    +
    +
    +static bool parseArgs(int argc, char **argv, OptionsT *opt) {
    +    memset(opt, 0, sizeof(*opt));
    +
    +    for (int32_t i = 1; i < argc; i++) {
    +        if (strcmp(argv[i], "--steps") == 0 && i + 1 < argc) {
    +            opt->steps = (int32_t)strtol(argv[++i], NULL, 10);
    +        } else if (strcmp(argv[i], "--data") == 0 && i + 1 < argc) {
    +            opt->dataDir = argv[++i];
    +        } else if (strcmp(argv[i], "--compact") == 0) {
    +            opt->compact = true;
    +        } else if (strcmp(argv[i], "--quiet") == 0) {
    +            opt->quiet = true;
    +        } else if (argv[i][0] == '-') {
    +            fprintf(stderr, "basrun: unknown option %s\n", argv[i]);
    +            return false;
    +        } else if (opt->program) {
    +            fprintf(stderr, "basrun: only one program may be given\n");
    +            return false;
    +        } else {
    +            opt->program = argv[i];
    +        }
    +    }
    +
    +    return opt->program != NULL;
    +}
    +
    +
    +static void programDir(const char *path, char *out, int32_t outSize) {
    +    snprintf(out, outSize, "%s", path);
    +    char *sep = platformPathDirEnd(out);
    +
    +    if (sep) {
    +        *sep = '\0';
    +    } else {
    +        out[0] = '.';
    +        out[1] = '\0';
    +    }
    +}
    +
    +
    +static int32_t runModule(BasModuleT *mod, const OptionsT *opt) {
    +    BasVmT *vm = basVmCreate();
    +
    +    if (!vm) {
    +        fprintf(stderr, "basrun: out of memory creating the VM\n");
    +        return BASRUN_EXIT_USAGE;
    +    }
    +
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, hostPrint, NULL);
    +    basVmSetInputCallback(vm, hostInput, NULL);
    +    basVmSetDoEventsCallback(vm, hostDoEvents, NULL);
    +
    +    char dir[DVX_MAX_PATH];
    +    programDir(opt->program, dir, sizeof(dir));
    +
    +    const char *dataDir = opt->dataDir ? opt->dataDir : dir;
    +    snprintf(vm->appPath,   DVX_MAX_PATH, "%s", dir);
    +    snprintf(vm->appConfig, DVX_MAX_PATH, "%s", dataDir);
    +    snprintf(vm->appData,   DVX_MAX_PATH, "%s", dataDir);
    +
    +    // Run in slices so a --steps cap can be enforced across the whole run.
    +    basVmSetStepLimit(vm, BASRUN_STEP_SLICE);
    +
    +    int32_t      total  = 0;
    +    int32_t      exit   = BASRUN_EXIT_OK;
    +    BasVmResultE result = basVmRun(vm);
    +
    +    while (result == BAS_VM_STEP_LIMIT) {
    +        total += vm->stepCount;
    +
    +        if (opt->steps > 0 && total >= opt->steps) {
    +            if (!opt->quiet) {
    +                fprintf(stderr, "basrun: step cap of %d reached at line %d\n", (int)opt->steps, (int)vm->currentLine);
    +            }
    +
    +            exit = BASRUN_EXIT_STEPS;
    +            break;
    +        }
    +
    +        result = basVmRun(vm);
    +    }
    +
    +    if (result != BAS_VM_HALTED && result != BAS_VM_OK && result != BAS_VM_STEP_LIMIT) {
    +        int32_t errNum = vm->errorNumber;
    +
    +        if (!opt->quiet) {
    +            fprintf(stderr, "basrun: runtime error %d at line %d: %s\n", (int)errNum, (int)vm->errorLine, basVmGetError(vm));
    +        }
    +
    +        if (errNum <= 0) {
    +            errNum = 1;
    +        }
    +
    +        exit = errNum > BASRUN_EXIT_ERR_MAX ? BASRUN_EXIT_ERR_MAX : errNum;
    +    }
    +
    +    fflush(stdout);
    +    basVmDestroy(vm);
    +    return exit;
    +}
    +
    +
    +static void usage(void) {
    +    fprintf(stderr, "DVX BASIC headless runner\n\n");
    +    fprintf(stderr, "Usage: basrun [--steps N] [--data DIR] [--compact] [--quiet] program.app|program.bas|project.dbp\n");
    +}
    +
    +
    +int main(int argc, char **argv) {
    +    OptionsT opt;
    +
    +    if (!parseArgs(argc, argv, &opt)) {
    +        usage();
    +        return BASRUN_EXIT_USAGE;
    +    }
    +
    +    BasModuleT *mod      = NULL;
    +    bool        compiled = false;
    +
    +    if (dvxHasExt(opt.program, ".app")) {
    +        mod = loadApp(opt.program);
    +    } else if (dvxHasExt(opt.program, ".dbp")) {
    +        mod      = loadDbp(opt.program);
    +        compiled = true;
    +    } else {
    +        mod      = loadBas(opt.program);
    +        compiled = true;
    +    }
    +
    +    if (!mod) {
    +        return BASRUN_EXIT_USAGE;
    +    }
    +
    +    if (opt.compact && compiled) {
    +        basStripModule(mod);
    +        basCompactBytecode(mod);
    +    }
    +
    +    int32_t exit = runModule(mod, &opt);
    +    basModuleFree(mod);
    +    return exit;
    +}
    diff --git a/src/apps/kpunch/dvxbasic/test_compact.c b/src/apps/kpunch/dvxbasic/test_compact.c
    index 100f7f0..4bcef94 100644
    --- a/src/apps/kpunch/dvxbasic/test_compact.c
    +++ b/src/apps/kpunch/dvxbasic/test_compact.c
    @@ -30,6 +30,7 @@
     #include "compiler/strip.h"
     #include "compiler/compact.h"
     #include "runtime/vm.h"
    +#include "compiler/opcodes.h"
     #include "runtime/values.h"
     
     #include 
    @@ -40,6 +41,12 @@
     
     #define MAX_OUT 65536
     
    +// Every possible opcode byte, for the operand metadata sweep.
    +#define TEST_OPCODE_SPACE 256
    +
    +// A byte no OP_* define claims (verified against opcodes.h).
    +#define TEST_UNDEFINED_OPCODE 0xFD
    +
     
     typedef struct {
         char   *buf;
    @@ -49,9 +56,17 @@ typedef struct {
     
     
     // Function prototypes
    -static void    captureCallback(void *ctx, const char *text, bool newline);
    -static int32_t runAndCapture(const char *source, bool compact, char *outBuf, int32_t outCap);
    -static void    testCompact(const char *name, const char *source);
    +static void        captureCallback(void *ctx, const char *text, bool newline);
    +static void        expectNoCompact(const char *name, const uint8_t *code, int32_t codeLen);
    +static BasModuleT *makeRawModule(const uint8_t *code, int32_t codeLen);
    +static int32_t     runAndCapture(const char *source, bool compact, char *outBuf, int32_t outCap);
    +static void        testCompact(const char *name, const char *source);
    +static void        testCraftedTables(void);
    +static void        testOperandKinds(void);
    +
    +
    +static int32_t sTotal  = 0;
    +static int32_t sFailed = 0;
     
     
     static void captureCallback(void *ctx, const char *text, bool newline) {
    @@ -71,6 +86,34 @@ static void captureCallback(void *ctx, const char *text, bool newline) {
     }
     
     
    +static void expectNoCompact(const char *name, const uint8_t *code, int32_t codeLen) {
    +    sTotal++;
    +
    +    BasModuleT *mod     = makeRawModule(code, codeLen);
    +    int32_t     removed = basCompactBytecode(mod);
    +
    +    if (removed != 0 || mod->codeLen != codeLen || memcmp(mod->code, code, codeLen) != 0) {
    +        printf("FAIL: %s (removed %d)\n", name, (int32_t)removed);
    +        sFailed++;
    +    } else {
    +        printf("PASS: %s\n", name);
    +    }
    +
    +    basModuleFree(mod);
    +}
    +
    +
    +static BasModuleT *makeRawModule(const uint8_t *code, int32_t codeLen) {
    +    BasModuleT *mod = (BasModuleT *)calloc(1, sizeof(BasModuleT));
    +
    +    mod->code = (uint8_t *)malloc(codeLen);
    +    memcpy(mod->code, code, codeLen);
    +    mod->codeLen    = codeLen;
    +    mod->entryPoint = 0;
    +    return mod;
    +}
    +
    +
     static int32_t runAndCapture(const char *source, bool compact, char *outBuf, int32_t outCap) {
         BasParserT parser;
         basParserInit(&parser, source, (int32_t)strlen(source));
    @@ -126,10 +169,17 @@ static int32_t runAndCapture(const char *source, bool compact, char *outBuf, int
     }
     
     
    -static int32_t sTotal  = 0;
    -static int32_t sFailed = 0;
     
     
    +// A module built directly from raw bytes, exercising validation paths a
    +// compiled program can never produce.
    +
    +
    +// Compaction must refuse the stream (return 0) and leave it untouched.
    +
    +
    +// Crafted proc / form-var tables: out-of-range entries must abort
    +// compaction, a negative init address (no init code) must not.
     static void testCompact(const char *name, const char *source) {
         sTotal++;
     
    @@ -157,6 +207,114 @@ static void testCompact(const char *name, const char *source) {
     }
     
     
    +static void testCraftedTables(void) {
    +    static const uint8_t okCode[] = { OP_LINE, 1, 0, OP_HALT };
    +
    +    {
    +        sTotal++;
    +
    +        BasModuleT *mod = makeRawModule(okCode, (int32_t)sizeof(okCode));
    +
    +        mod->procs             = (BasProcEntryT *)calloc(1, sizeof(BasProcEntryT));
    +        mod->procCount         = 1;
    +        mod->procs[0].codeAddr = 999;
    +
    +        if (basCompactBytecode(mod) != 0) {
    +            printf("FAIL: proc codeAddr out of range compacted\n");
    +            sFailed++;
    +        } else {
    +            printf("PASS: proc codeAddr out of range refused\n");
    +        }
    +
    +        basModuleFree(mod);
    +    }
    +
    +    {
    +        sTotal++;
    +
    +        BasModuleT *mod = makeRawModule(okCode, (int32_t)sizeof(okCode));
    +
    +        mod->formVarInfo                  = (BasFormVarInfoT *)calloc(1, sizeof(BasFormVarInfoT));
    +        mod->formVarInfoCount             = 1;
    +        mod->formVarInfo[0].initCodeAddr  = -1;
    +        mod->formVarInfo[0].initCodeLen   = 0;
    +
    +        if (basCompactBytecode(mod) <= 0) {
    +            printf("FAIL: negative init addr blocked compaction\n");
    +            sFailed++;
    +        } else {
    +            printf("PASS: negative init addr skipped\n");
    +        }
    +
    +        basModuleFree(mod);
    +    }
    +
    +    {
    +        sTotal++;
    +
    +        BasModuleT *mod = makeRawModule(okCode, (int32_t)sizeof(okCode));
    +
    +        mod->formVarInfo                  = (BasFormVarInfoT *)calloc(1, sizeof(BasFormVarInfoT));
    +        mod->formVarInfoCount             = 1;
    +        mod->formVarInfo[0].initCodeAddr  = 999;
    +        mod->formVarInfo[0].initCodeLen   = 1;
    +
    +        if (basCompactBytecode(mod) != 0) {
    +            printf("FAIL: init addr out of range compacted\n");
    +            sFailed++;
    +        } else {
    +            printf("PASS: init addr out of range refused\n");
    +        }
    +
    +        basModuleFree(mod);
    +    }
    +}
    +
    +
    +// Operand metadata: every opcode byte must report a well-formed size,
    +// and known opcodes their documented one.
    +static void testOperandKinds(void) {
    +    sTotal++;
    +
    +    bool ok = true;
    +
    +    for (int32_t op = 0; op < TEST_OPCODE_SPACE; op++) {
    +        int32_t size = basOpcodeOperandSize((uint8_t)op);
    +
    +        switch (size) {
    +            case BAS_OPERAND_NONE:
    +            case BAS_OPERAND_U8:
    +            case BAS_OPERAND_U16:
    +            case BAS_OPERAND_U8_U16:
    +            case BAS_OPERAND_I32:
    +            case BAS_OPERAND_FOR:
    +            case BAS_OPERAND_CALL_EXTERN:
    +            case BAS_OPERAND_F64:
    +            case BAS_OPERAND_UNKNOWN:
    +                break;
    +
    +            default:
    +                printf("FAIL: opcode 0x%02X reports operand size %d\n", (int32_t)op, (int32_t)size);
    +                ok = false;
    +                break;
    +        }
    +    }
    +
    +    if (basOpcodeOperandSize(OP_LINE) != BAS_OPERAND_U16 || basOpcodeOperandSize(OP_CALL) != BAS_OPERAND_I32 || basOpcodeOperandSize(OP_FOR_NEXT) != BAS_OPERAND_FOR || basOpcodeOperandSize(TEST_UNDEFINED_OPCODE) != BAS_OPERAND_UNKNOWN) {
    +        printf("FAIL: known opcode operand sizes\n");
    +        ok = false;
    +    }
    +
    +    if (ok) {
    +        printf("PASS: operand size table well-formed\n");
    +    } else {
    +        sFailed++;
    +    }
    +}
    +
    +
    +
    +
     int main(void) {
         printf("DVX BASIC Bytecode Compaction Tests\n");
         printf("====================================\n\n");
    @@ -467,6 +625,40 @@ int main(void) {
             "PRINT\n"
         );
     
    +
    +    // ---- Crafted modules: compaction must refuse invalid streams ----
    +
    +    {
    +        static const uint8_t badCall[]    = { OP_LINE, 1, 0, OP_CALL, 0xFF, 0xFF, 0, 0, OP_HALT };
    +        static const uint8_t badJmp[]     = { OP_LINE, 1, 0, OP_JMP, 0x00, 0x70, OP_HALT };
    +        static const uint8_t badForNext[] = { OP_LINE, 1, 0, OP_FOR_NEXT, 0, 0, 0, 0x00, 0x70, OP_HALT };
    +        static const uint8_t badForInit[] = { OP_LINE, 1, 0, OP_FOR_INIT, 0, 0, 0, 0x00, 0x70, OP_HALT };
    +        static const uint8_t truncated[]  = { OP_LINE, 1, 0, OP_JMP, 0 };
    +        static const uint8_t unknownOp[]  = { OP_LINE, 1, 0, TEST_UNDEFINED_OPCODE, OP_HALT };
    +
    +        expectNoCompact("OP_CALL target out of range refused", badCall, (int32_t)sizeof(badCall));
    +        expectNoCompact("OP_JMP target out of range refused", badJmp, (int32_t)sizeof(badJmp));
    +        expectNoCompact("OP_FOR_NEXT target out of range refused", badForNext, (int32_t)sizeof(badForNext));
    +        expectNoCompact("OP_FOR_INIT target out of range refused", badForInit, (int32_t)sizeof(badForInit));
    +        expectNoCompact("Truncated instruction refused", truncated, (int32_t)sizeof(truncated));
    +        expectNoCompact("Unknown opcode refused", unknownOp, (int32_t)sizeof(unknownOp));
    +    }
    +
    +    testCraftedTables();
    +    testOperandKinds();
    +
    +    // ---- Strip: generated proc name colliding with a pool constant ----
    +
    +    testCompact("Strip mangled name collision",
    +        "DIM s AS STRING\n"
    +        "s = \"F0\"\n"
    +        "CALL Foo\n"
    +        "PRINT s\n"
    +        "SUB Foo\n"
    +        "    PRINT \"hi\"\n"
    +        "END SUB\n"
    +    );
    +
         printf("\n%d/%d tests passed\n", (int)(sTotal - sFailed), (int)sTotal);
         return sFailed > 0 ? 1 : 0;
     }
    diff --git a/src/apps/kpunch/dvxbasic/test_compiler.c b/src/apps/kpunch/dvxbasic/test_compiler.c
    index f2b2810..ffa4b35 100644
    --- a/src/apps/kpunch/dvxbasic/test_compiler.c
    +++ b/src/apps/kpunch/dvxbasic/test_compiler.c
    @@ -30,6 +30,7 @@
     
     #include 
     #include 
    +#include 
     #include 
     
     // Count of failed checks.  main() returns non-zero when this is > 0 so
    @@ -41,6 +42,16 @@ static int32_t gFailCount = 0;
     // text of a test program.
     #define CAPTURE_MAX 8192
     
    +// Generated-source limit tests (testCompilerLimits / testLexerLimits).
    +#define TEST_LIMIT_CONSTS      65600         // one past the 16-bit constant pool
    +#define TEST_LIMIT_BIGLINES    9000          // pushes codeLen past 64KB
    +#define TEST_LIMIT_JUMPBODY    3500          // IF body wider than an int16 jump
    +#define TEST_LIMIT_GLOBALS     520           // one past BAS_VM_MAX_GLOBALS
    +#define TEST_LIMIT_SRC_CAP     (2 * 1024 * 1024)
    +#define TEST_LONG_STRING_LEN   300           // past BAS_MAX_STRING_LEN
    +#define TEST_LONG_STRING_SRC   512
    +#define TEST_TOKEN_NAME_SWEEP  512           // covers the whole token enum
    +
     typedef struct {
         char    buf[CAPTURE_MAX];
         int32_t len;
    @@ -49,8 +60,23 @@ typedef struct {
     // Function prototypes
     static void captureAppend(CaptureT *c, const char *s);
     static void capturePrint(void *ctx, const char *text, bool newline);
    +static void compileOnly(const char *name, const char *source);
     static void expectCompileError(const char *name, const char *source, const char *needle);
    +static void expectValidatorParse(const char *name, const char *source, const char *needle);
     static void runProgram(const char *name, const char *source, const char *expected);
    +static bool stubIsMethodValid(void *ctx, const char *wgtType, const char *methodName);
    +static bool stubIsPropValid(void *ctx, const char *wgtType, const char *propName);
    +static const char *stubLookupCtrlType(void *ctx, const char *ctrlName);
    +static int32_t stubMsgBox(void *ctx, const char *msg, int32_t flags, const char *title);
    +static void testCompilerLimits(void);
    +static void testKeywordApi(void);
    +static void testLexerLimits(void);
    +static void testTokenNames(void);
    +
    +// Headless stand-in for the form runtime: MsgBox answers OK so programs
    +// that show one still run to completion (every other form/control
    +// operation raises error 1000 without a callback).
    +static const BasUiCallbacksT sStubUi = { .msgBox = stubMsgBox };
     
     
     static void captureAppend(CaptureT *c, const char *s) {
    @@ -81,8 +107,40 @@ static void capturePrint(void *ctx, const char *text, bool newline) {
     }
     
     
    +
    +// Compile source expecting success without running it (constructs whose
    +// execution needs a form runtime or host I/O).
    +static void compileOnly(const char *name, const char *source) {
    +    printf("=== %s ===\n", name);
    +
    +    BasParserT parser;
    +    basParserInit(&parser, source, (int32_t)strlen(source));
    +
    +    if (!basParse(&parser)) {
    +        printf("FAIL: compile error: %s\n\n", parser.error);
    +        gFailCount++;
    +        basParserFree(&parser);
    +        return;
    +    }
    +
    +    BasModuleT *mod = basParserBuildModule(&parser);
    +    basParserFree(&parser);
    +
    +    if (!mod) {
    +        printf("FAIL: module build failed\n\n");
    +        gFailCount++;
    +        return;
    +    }
    +
    +    basModuleFree(mod);
    +    printf("OK\n\n");
    +}
    +
    +
     // Compile source expecting it to be rejected; the error text must
     // contain needle.
    +
    +
     static void expectCompileError(const char *name, const char *source, const char *needle) {
         printf("=== %s ===\n", name);
     
    @@ -105,9 +163,47 @@ static void expectCompileError(const char *name, const char *source, const char
     }
     
     
    +
    +// Compile with the stub control validator attached.  A NULL needle means
    +// the source must compile; otherwise it must fail with the needle in the
    +// error text.
    +static void expectValidatorParse(const char *name, const char *source, const char *needle) {
    +    static const BasCtrlValidatorT sValidator = { stubLookupCtrlType, stubIsMethodValid, stubIsPropValid, NULL };
    +
    +    printf("=== %s ===\n", name);
    +
    +    BasParserT parser;
    +    basParserInit(&parser, source, (int32_t)strlen(source));
    +    basParserSetValidator(&parser, &sValidator);
    +
    +    bool ok = basParse(&parser);
    +
    +    if (needle == NULL) {
    +        if (ok) {
    +            printf("OK\n\n");
    +        } else {
    +            printf("FAIL: compile error: %s\n\n", parser.error);
    +            gFailCount++;
    +        }
    +    } else if (ok) {
    +        printf("FAIL: compiled but expected error containing [%s]\n\n", needle);
    +        gFailCount++;
    +    } else if (strstr(parser.error, needle) == NULL) {
    +        printf("FAIL: error [%s] does not contain [%s]\n\n", parser.error, needle);
    +        gFailCount++;
    +    } else {
    +        printf("OK (expected error: %s)\n\n", parser.error);
    +    }
    +
    +    basParserFree(&parser);
    +}
    +
    +
     // Compile and run source.  PRINT output is captured and, when expected
     // is not NULL, must match it exactly; a compile error or a VM error is
     // always a failure.
    +
    +
     static void runProgram(const char *name, const char *source, const char *expected) {
         printf("=== %s ===\n", name);
     
    @@ -139,6 +235,7 @@ static void runProgram(const char *name, const char *source, const char *expecte
         BasVmT *vm = basVmCreate();
         basVmLoadModule(vm, mod);
         basVmSetPrintCallback(vm, capturePrint, &cap);
    +    basVmSetUiCallbacks(vm, &sStubUi);
     
         BasVmResultE result = basVmRun(vm);
     
    @@ -158,6 +255,165 @@ static void runProgram(const char *name, const char *source, const char *expecte
     }
     
     
    +static bool stubIsMethodValid(void *ctx, const char *wgtType, const char *methodName) {
    +    (void)ctx;
    +    (void)wgtType;
    +    return strcasecmp(methodName, "BadMethod") != 0;
    +}
    +
    +
    +static bool stubIsPropValid(void *ctx, const char *wgtType, const char *propName) {
    +    (void)ctx;
    +    (void)wgtType;
    +    return strcasecmp(propName, "BadProp") != 0;
    +}
    +
    +
    +static const char *stubLookupCtrlType(void *ctx, const char *ctrlName) {
    +    (void)ctx;
    +
    +    if (strcasecmp(ctrlName, "Text1") == 0) {
    +        return "TextBox";
    +    }
    +
    +    return NULL;
    +}
    +
    +
    +static int32_t stubMsgBox(void *ctx, const char *msg, int32_t flags, const char *title) {
    +    (void)ctx;
    +    (void)msg;
    +    (void)flags;
    +    (void)title;
    +    return 1;
    +}
    +
    +
    +// Compiler hard limits that need generated sources: constant pool
    +// overflow, module size, relative jump span, global slot table.
    +static void testCompilerLimits(void) {
    +    char *src = (char *)malloc(TEST_LIMIT_SRC_CAP);
    +
    +    if (src == NULL) {
    +        printf("FAIL: limits source buffer\n");
    +        gFailCount++;
    +        return;
    +    }
    +
    +    int32_t len = 0;
    +
    +    // One more distinct string constant than the pool can index.
    +    for (int32_t i = 0; i < TEST_LIMIT_CONSTS; i++) {
    +        len += snprintf(src + len, TEST_LIMIT_SRC_CAP - len, "A$ = \"c%d\"\n", (int32_t)i);
    +    }
    +    expectCompileError("Constant pool overflow rejected", src, "Too many string constants");
    +
    +    // Code past the 16-bit absolute address range.
    +    len = 0;
    +    for (int32_t i = 0; i < TEST_LIMIT_BIGLINES; i++) {
    +        len += snprintf(src + len, TEST_LIMIT_SRC_CAP - len, "L = 123456\n");
    +    }
    +    expectCompileError("Oversized module rejected", src, "Module too large");
    +
    +    // A single IF body wider than a signed 16-bit jump.
    +    len = snprintf(src, TEST_LIMIT_SRC_CAP, "DIM z AS INTEGER\nIF z = 0 THEN\n");
    +    for (int32_t i = 0; i < TEST_LIMIT_JUMPBODY; i++) {
    +        len += snprintf(src + len, TEST_LIMIT_SRC_CAP - len, "L = 123456\n");
    +    }
    +    len += snprintf(src + len, TEST_LIMIT_SRC_CAP - len, "END IF\n");
    +    expectCompileError("Oversized jump span rejected", src, "Jump distance exceeds 16-bit limit");
    +
    +    // More module-level variables than the VM has global slots.
    +    len = 0;
    +    for (int32_t i = 0; i < TEST_LIMIT_GLOBALS; i++) {
    +        len += snprintf(src + len, TEST_LIMIT_SRC_CAP - len, "DIM v%d AS INTEGER\n", (int32_t)i);
    +    }
    +    expectCompileError("Too many globals rejected", src, "Too many module-level variables");
    +
    +    free(src);
    +}
    +
    +
    +// The keyword enumeration API used by the IDE's syntax highlighter.
    +static void testKeywordApi(void) {
    +    printf("=== lexer keyword API ===\n");
    +
    +    int32_t n  = basLexerKeywordCount();
    +    bool    ok = (n > 0);
    +
    +    if (basLexerKeywordAt(-1) != NULL || basLexerKeywordAt(n) != NULL) {
    +        ok = false;
    +    }
    +
    +    if (basLexerKeywordClass(-1) != BAS_KW_CLASS_OTHER || basLexerKeywordClass(n) != BAS_KW_CLASS_OTHER) {
    +        ok = false;
    +    }
    +
    +    int32_t typeCount    = 0;
    +    int32_t literalCount = 0;
    +
    +    for (int32_t i = 0; i < n; i++) {
    +        if (basLexerKeywordAt(i) == NULL) {
    +            ok = false;
    +        }
    +
    +        BasKeywordClassE cls = basLexerKeywordClass(i);
    +
    +        if (cls == BAS_KW_CLASS_TYPE) {
    +            typeCount++;
    +        } else if (cls == BAS_KW_CLASS_LITERAL) {
    +            literalCount++;
    +        }
    +    }
    +
    +    if (typeCount == 0 || literalCount == 0) {
    +        ok = false;
    +    }
    +
    +    if (ok) {
    +        printf("OK (%d keywords)\n\n", (int32_t)n);
    +    } else {
    +        printf("FAIL: keyword API\n\n");
    +        gFailCount++;
    +    }
    +}
    +
    +
    +// Lexer limits needing generated sources: over-long string literal.
    +static void testLexerLimits(void) {
    +    char src[TEST_LONG_STRING_SRC];
    +    int32_t len = snprintf(src, sizeof(src), "A$ = \"");
    +
    +    for (int32_t i = 0; i < TEST_LONG_STRING_LEN; i++) {
    +        src[len++] = 'x';
    +    }
    +
    +    len += snprintf(src + len, sizeof(src) - len, "\"\n");
    +    expectCompileError("Over-long string literal rejected", src, "String literal too long");
    +}
    +
    +
    +// Every token type must map to a printable name (unknowns report "?").
    +static void testTokenNames(void) {
    +    printf("=== token names ===\n");
    +
    +    bool ok = true;
    +
    +    for (int32_t i = 0; i < TEST_TOKEN_NAME_SWEEP; i++) {
    +        if (basTokenName((BasTokenTypeE)i) == NULL) {
    +            ok = false;
    +        }
    +    }
    +
    +    if (ok) {
    +        printf("OK\n\n");
    +    } else {
    +        printf("FAIL: basTokenName returned NULL\n\n");
    +        gFailCount++;
    +    }
    +}
    +
    +
     int main(void) {
         printf("DVX BASIC Compiler Tests\n");
         printf("========================\n\n");
    @@ -956,7 +1212,7 @@ int main(void) {
             "PRINT USING \"###.##-\"; -42.5\n"
             "PRINT USING \"###.##-\"; 42.5\n"
             "PRINT USING \"#.##^^^^\"; 1234.5\n",
    -        "*1,234.50\n$  42.50\n+ 42.50\n- 42.50\n 42.50-\n 42.50 \n1.23E+03\n"
    +        "**1,234.50\n    $42.50\n +42.50\n -42.50\n 42.50-\n 42.50 \n1.23E+03\n"
         );
     
         // Test: DEFINT -- every A-Z variable is INTEGER, so 3.7 rounds on store
    @@ -1775,9 +2031,9 @@ int main(void) {
     
         runProgram("PRINT comma separator",
             "PRINT 1, 2, 3\n",
    -        "1 \t2 \t3 \n"
    +        "1             2             3 \n"
         );
    -    // Expected: 123
    +    // Expected: 1 and 2 and 3 at the start of 14-column print zones
     
         runProgram("PRINT bare newline",
             "PRINT \"a\"\n"
    @@ -3456,6 +3712,1101 @@ int main(void) {
             "STATIC name"
         );
     
    +
    +    // ---- Coverage completion: lexer edges ----
    +
    +    expectCompileError("Unexpected character rejected",
    +        "PRINT 1 ~\n",
    +        "Unexpected character"
    +    );
    +
    +    expectCompileError("Empty hex literal rejected",
    +        "A = &H\n",
    +        "Empty hexadecimal"
    +    );
    +
    +    expectCompileError("Wide hex literal rejected",
    +        "A = &H1FFFFFFFF\n",
    +        "exceeds 32 bits"
    +    );
    +
    +    runProgram("Hex, octal, binary literal forms",
    +        "PRINT &hff\n"
    +        "PRINT &O17\n"
    +        "PRINT &B101\n"
    +        "PRINT &HAB&\n",
    +        NULL
    +    );
    +
    +    expectCompileError("Unterminated string literal rejected",
    +        "A$ = \"abc\n",
    +        "Unterminated string literal"
    +    );
    +
    +    runProgram("CRLF line endings",
    +        "PRINT 1\r\nPRINT 2\r\n",
    +        NULL
    +    );
    +
    +    runProgram("Line continuation with CRLF",
    +        "A = 1 + _\r\n2\r\nPRINT A\r\n",
    +        NULL
    +    );
    +
    +    expectCompileError("Underscore without newline is not a continuation",
    +        "A = 5 _ x\n",
    +        "end of statement"
    +    );
    +
    +    expectCompileError("Dangling exponent marker",
    +        "B = 1E\n",
    +        "end of statement"
    +    );
    +
    +    runProgram("D exponent marker",
    +        "PRINT 1D2\n",
    +        NULL
    +    );
    +
    +    runProgram("All comparison operators",
    +        "PRINT 1 < 2\n"
    +        "PRINT 2 <= 2\n"
    +        "PRINT 3 > 2\n"
    +        "PRINT 3 >= 4\n"
    +        "PRINT 1 = 1\n"
    +        "PRINT 1 <> 1\n",
    +        NULL
    +    );
    +
    +    testKeywordApi();
    +    testTokenNames();
    +    testLexerLimits();
    +
    +    // ---- Coverage completion: statements and declarations ----
    +
    +    expectCompileError("BEGINFORM without name string rejected",
    +        "BEGINFORM 1\n",
    +        "form name string"
    +    );
    +
    +    expectCompileError("Over-long form name rejected",
    +        "BEGINFORM \"A234567890123456789012345678901234567890123456789012345678901234567890\"\n",
    +        "Form name too long"
    +    );
    +
    +    expectCompileError("ENDFORM without BEGINFORM rejected",
    +        "ENDFORM\n",
    +        "ENDFORM without BEGINFORM"
    +    );
    +
    +    compileOnly("Form scope ended then global lookup",
    +        "BEGINFORM \"F1\"\n"
    +        "DIM fv AS INTEGER\n"
    +        "ENDFORM\n"
    +        "FUNCTION G\n"
    +        "G = 1\n"
    +        "END FUNCTION\n"
    +        "PRINT G\n"
    +    );
    +
    +    compileOnly("DIM with bounds inside form init block",
    +        "BEGINFORM \"F1\"\n"
    +        "DIM fa(3) AS INTEGER\n"
    +        "ENDFORM\n"
    +    );
    +
    +    expectCompileError("CONST without name rejected",
    +        "CONST 1 = 2\n",
    +        "constant name"
    +    );
    +
    +    expectCompileError("CONST of a non-literal rejected",
    +        "DIM b AS INTEGER\n"
    +        "CONST A = b\n",
    +        "Constant value must be a literal"
    +    );
    +
    +    expectCompileError("Duplicate CONST rejected",
    +        "CONST A = 1\n"
    +        "CONST A = 2\n",
    +        "Duplicate constant or symbol table full"
    +    );
    +
    +    runProgram("CONST literal forms",
    +        "CONST A = -5\n"
    +        "CONST B = -2.5\n"
    +        "CONST C = TRUE\n"
    +        "CONST D = FALSE\n"
    +        "CONST S$ = \"hi\"\n"
    +        "PRINT A\n"
    +        "PRINT B\n"
    +        "PRINT C\n"
    +        "PRINT D\n"
    +        "PRINT S$\n",
    +        NULL
    +    );
    +
    +    runProgram("DATA negatives and bare values",
    +        "DATA -5, -2.5, -, hello, 70000, -70000\n"
    +        "DIM a AS INTEGER\n"
    +        "DIM b AS DOUBLE\n"
    +        "DIM s1$\n"
    +        "DIM s2$\n"
    +        "DIM l1 AS LONG\n"
    +        "DIM l2 AS LONG\n"
    +        "READ a, b, s1$, s2$, l1, l2\n"
    +        "PRINT a\n"
    +        "PRINT s1$\n"
    +        "PRINT s2$\n"
    +        "PRINT l1\n",
    +        NULL
    +    );
    +
    +    expectCompileError("Bad DATA value rejected",
    +        "DATA (\n",
    +        "Expected DATA value"
    +    );
    +
    +    expectCompileError("READ into CONST rejected",
    +        "CONST K = 1\n"
    +        "DATA 2\n"
    +        "READ K\n",
    +        "Cannot assign to a constant"
    +    );
    +
    +    expectCompileError("FOR over a SUB name rejected",
    +        "SUB S\n"
    +        "END SUB\n"
    +        "FOR S = 1 TO 2\n"
    +        "NEXT\n",
    +        "Cannot assign to 'S'"
    +    );
    +
    +    expectCompileError("DECLARE of something else rejected",
    +        "DECLARE PRINT\n",
    +        "Expected SUB, FUNCTION, or LIBRARY after DECLARE"
    +    );
    +
    +    expectCompileError("DECLARE without name rejected",
    +        "DECLARE SUB 1\n",
    +        "subroutine/function name"
    +    );
    +
    +    expectCompileError("DECLARE clashing with a variable rejected",
    +        "DIM X AS INTEGER\n"
    +        "DECLARE SUB X ()\n",
    +        "already declared"
    +    );
    +
    +    expectCompileError("DECLARE LIBRARY without name string rejected",
    +        "DECLARE LIBRARY 1\n",
    +        "library name string"
    +    );
    +
    +    expectCompileError("Statement inside DECLARE LIBRARY rejected",
    +        "DECLARE LIBRARY \"foo\"\n"
    +        "PRINT 1\n"
    +        "END DECLARE\n",
    +        "Expected DECLARE or END DECLARE"
    +    );
    +
    +    expectCompileError("DECLARE of non-proc inside LIBRARY block rejected",
    +        "DECLARE LIBRARY \"foo\"\n"
    +        "DECLARE PRINT\n"
    +        "END DECLARE\n",
    +        "Expected SUB or FUNCTION in DECLARE LIBRARY block"
    +    );
    +
    +    expectCompileError("Library DECLARE without name rejected",
    +        "DECLARE LIBRARY \"foo\"\n"
    +        "DECLARE SUB 1\n"
    +        "END DECLARE\n",
    +        "function name"
    +    );
    +
    +    expectCompileError("END of wrong block inside DECLARE LIBRARY rejected",
    +        "DECLARE LIBRARY \"foo\"\n"
    +        "END IF\n",
    +        "Expected DECLARE after END in DECLARE LIBRARY block"
    +    );
    +
    +    expectCompileError("Library function clashing with a variable rejected",
    +        "DIM Q AS INTEGER\n"
    +        "DECLARE LIBRARY \"foo\"\n"
    +        "DECLARE SUB Q ()\n"
    +        "END DECLARE\n",
    +        "already declared"
    +    );
    +
    +    compileOnly("DECLARE LIBRARY block",
    +        "DECLARE LIBRARY \"dvx\"\n"
    +        "DECLARE SUB DoBeep ()\n"
    +        "DECLARE FUNCTION AddUp% (a AS INTEGER, b AS INTEGER)\n"
    +        "END DECLARE\n"
    +    );
    +
    +    expectCompileError("DEF without FN name rejected",
    +        "DEF 1\n",
    +        "function name (FNname)"
    +    );
    +
    +    expectCompileError("DEF name without FN prefix rejected",
    +        "DEF Foo(x) = x\n",
    +        "DEF function name must start with FN"
    +    );
    +
    +    expectCompileError("DEF clashing with a variable rejected",
    +        "DIM FNA AS INTEGER\n"
    +        "DEF FNA(x) = x\n",
    +        "Could not register DEF function"
    +    );
    +
    +    runProgram("DEF FN definition and call",
    +        "DEF FNDouble(x) = x * 2\n"
    +        "PRINT FNDouble(4)\n",
    +        NULL
    +    );
    +
    +    expectCompileError("DEFINT without letter rejected",
    +        "DEFINT 1\n",
    +        "letter or letter range"
    +    );
    +
    +    expectCompileError("DEFINT multi-letter token rejected",
    +        "DEFINT _x\n",
    +        "Expected letter A-Z"
    +    );
    +
    +    expectCompileError("DEFINT dangling dash rejected",
    +        "DEFINT A-\n",
    +        "letter after '-'"
    +    );
    +
    +    expectCompileError("DEFINT bad range end rejected",
    +        "DEFINT A-_y\n",
    +        "Expected letter A-Z"
    +    );
    +
    +    expectCompileError("Duplicate DIM rejected",
    +        "DIM X AS INTEGER\n"
    +        "DIM X AS INTEGER\n",
    +        "already declared"
    +    );
    +
    +    expectCompileError("DIM clashing with a SUB rejected",
    +        "SUB S\n"
    +        "END SUB\n"
    +        "DIM S AS INTEGER\n",
    +        "already declared"
    +    );
    +
    +    expectCompileError("ERASE of a non-identifier rejected",
    +        "ERASE 1\n",
    +        "array variable name"
    +    );
    +
    +    expectCompileError("ERASE of a scalar rejected",
    +        "DIM x AS INTEGER\n"
    +        "ERASE x\n",
    +        "ERASE requires an array variable"
    +    );
    +
    +    runProgram("ERASE static and dynamic arrays",
    +        "DIM a(3) AS INTEGER\n"
    +        "a(1) = 5\n"
    +        "ERASE a\n"
    +        "PRINT a(1)\n"
    +        "DIM d() AS INTEGER\n"
    +        "REDIM d(5)\n"
    +        "ERASE d\n"
    +        "PRINT \"ok\"\n",
    +        NULL
    +    );
    +
    +    expectCompileError("EXIT FUNCTION inside SUB rejected",
    +        "SUB S\n"
    +        "EXIT FUNCTION\n"
    +        "END SUB\n",
    +        "EXIT FUNCTION outside FUNCTION"
    +    );
    +
    +    expectCompileError("EXIT of a non-loop rejected",
    +        "EXIT PRINT\n",
    +        "Expected FOR, DO, SUB, or FUNCTION after EXIT"
    +    );
    +
    +    compileOnly("GET and PUT of a whole UDT record",
    +        "TYPE T\n"
    +        "x AS INTEGER\n"
    +        "s AS STRING * 8\n"
    +        "END TYPE\n"
    +        "DIM v AS T\n"
    +        "GET #1, , v\n"
    +        "PUT #1, , v\n"
    +    );
    +
    +    expectCompileError("UDT store of unknown field rejected",
    +        "TYPE T\n"
    +        "x AS INTEGER\n"
    +        "END TYPE\n"
    +        "DIM v AS T\n"
    +        "v.zz = 1\n",
    +        "Unknown field 'zz' in TYPE 'T'"
    +    );
    +
    +    expectCompileError("UDT field without assignment rejected",
    +        "TYPE T\n"
    +        "x AS INTEGER\n"
    +        "END TYPE\n"
    +        "DIM v AS T\n"
    +        "v.x 5\n",
    +        "Expected '='"
    +    );
    +
    +    expectCompileError("Nested UDT field without assignment rejected",
    +        "TYPE Inner\n"
    +        "x AS INTEGER\n"
    +        "END TYPE\n"
    +        "TYPE Outer\n"
    +        "i AS Inner\n"
    +        "END TYPE\n"
    +        "DIM v AS Outer\n"
    +        "v.i.x 5\n",
    +        "Expected '='"
    +    );
    +
    +    expectCompileError("STRING * with non-integer rejected",
    +        "DIM A AS STRING * X\n",
    +        "Expected integer after STRING *"
    +    );
    +
    +    expectCompileError("FOR without loop variable rejected",
    +        "FOR 1 = 1 TO 2\n"
    +        "NEXT\n",
    +        "loop variable"
    +    );
    +
    +    expectCompileError("FUNCTION without name rejected",
    +        "FUNCTION 1\n",
    +        "function name"
    +    );
    +
    +    expectCompileError("FUNCTION inside SUB rejected",
    +        "SUB S\n"
    +        "FUNCTION F\n"
    +        "END FUNCTION\n"
    +        "END SUB\n",
    +        "FUNCTION cannot be defined inside SUB or FUNCTION"
    +    );
    +
    +    expectCompileError("FUNCTION with bad parameter rejected",
    +        "FUNCTION F(1)\n"
    +        "END FUNCTION\n",
    +        "parameter name"
    +    );
    +
    +    expectCompileError("Duplicate FUNCTION body rejected",
    +        "FUNCTION F\n"
    +        "F = 1\n"
    +        "END FUNCTION\n"
    +        "FUNCTION F\n"
    +        "END FUNCTION\n",
    +        "Function 'F' is already defined"
    +    );
    +
    +    compileOnly("END statement inside FUNCTION body",
    +        "DECLARE FUNCTION F ()\n"
    +        "PRINT F\n"
    +        "FUNCTION F\n"
    +        "IF 1 = 2 THEN\n"
    +        "END\n"
    +        "END IF\n"
    +        "F = 3\n"
    +        "END FUNCTION\n"
    +    );
    +
    +    expectCompileError("GOSUB without label rejected",
    +        "GOSUB 1\n",
    +        "label name"
    +    );
    +
    +    expectCompileError("GOTO without label rejected",
    +        "GOTO 1\n",
    +        "label name"
    +    );
    +
    +    expectCompileError("IF without END IF rejected",
    +        "IF 1 = 1 THEN\n"
    +        "PRINT 1\n",
    +        "Expected END IF"
    +    );
    +
    +    expectCompileError("ELSEIF chain without END IF rejected",
    +        "IF 1 = 1 THEN\n"
    +        "PRINT 1\n"
    +        "ELSEIF 2 = 2 THEN\n"
    +        "PRINT 2\n",
    +        "Expected END IF"
    +    );
    +
    +    compileOnly("IF branches containing END",
    +        "DIM a AS INTEGER\n"
    +        "IF a = 1 THEN\n"
    +        "END\n"
    +        "ELSEIF a = 2 THEN\n"
    +        "END\n"
    +        "ELSE\n"
    +        "END\n"
    +        "END IF\n"
    +    );
    +
    +    expectCompileError("INPUT into a non-variable rejected",
    +        "INPUT 1\n",
    +        "variable name"
    +    );
    +
    +    expectCompileError("INPUT # into a non-variable rejected",
    +        "INPUT #1, 1\n",
    +        "variable name"
    +    );
    +
    +    compileOnly("INPUT prompt and file forms",
    +        "DIM v$\n"
    +        "DIM w$\n"
    +        "INPUT \"prompt\"; v$\n"
    +        "INPUT \"prompt\", v$\n"
    +        "INPUT v$\n"
    +        "INPUT #1, v$, w$\n"
    +    );
    +
    +    expectCompileError("LINE without INPUT rejected",
    +        "LINE PRINT\n",
    +        "Expected INPUT after LINE"
    +    );
    +
    +    expectCompileError("LINE INPUT without channel rejected",
    +        "DIM v$\n"
    +        "LINE INPUT v$\n",
    +        "Expected # for file channel in LINE INPUT"
    +    );
    +
    +    expectCompileError("LINE INPUT into a non-variable rejected",
    +        "LINE INPUT #1, 1\n",
    +        "variable name"
    +    );
    +
    +    expectCompileError("ON expression without GOTO or GOSUB rejected",
    +        "DIM x AS INTEGER\n"
    +        "ON x PRINT\n",
    +        "Expected GOTO or GOSUB after ON expression"
    +    );
    +
    +    expectCompileError("ON GOTO with bad label rejected",
    +        "DIM x AS INTEGER\n"
    +        "ON x GOTO 1\n",
    +        "label name"
    +    );
    +
    +    expectCompileError("ON GOSUB with bad second label rejected",
    +        "DIM x AS INTEGER\n"
    +        "ON x GOSUB a, 1\n"
    +        "a:\n"
    +        "RETURN\n",
    +        "label name"
    +    );
    +
    +    runProgram("ON GOTO dispatch",
    +        "DIM x AS INTEGER\n"
    +        "x = 2\n"
    +        "ON x GOTO a, b\n"
    +        "a:\n"
    +        "PRINT \"a\"\n"
    +        "END\n"
    +        "b:\n"
    +        "PRINT \"b\"\n"
    +        "END\n",
    +        NULL
    +    );
    +
    +    expectCompileError("ON ERROR without GOTO rejected",
    +        "ON ERROR PRINT\n",
    +        "Expected GOTO after ON ERROR"
    +    );
    +
    +    expectCompileError("ON ERROR GOTO of non-label rejected",
    +        "ON ERROR GOTO \"x\"\n",
    +        "label name or 0"
    +    );
    +
    +    compileOnly("ON ERROR GOTO 0",
    +        "ON ERROR GOTO 0\n"
    +    );
    +
    +    runProgram("ON ERROR GOTO backward label",
    +        "GOTO start\n"
    +        "h:\n"
    +        "PRINT \"handled\"\n"
    +        "RESUME NEXT\n"
    +        "start:\n"
    +        "ON ERROR GOTO h\n"
    +        "DIM a AS INTEGER\n"
    +        "a = 1 \\ 0\n"
    +        "PRINT \"end\"\n",
    +        NULL
    +    );
    +
    +    compileOnly("OPEN mode variants",
    +        "OPEN \"f\" FOR OUTPUT AS #1\n"
    +        "OPEN \"g\" FOR APPEND AS #2\n"
    +        "OPEN \"h\" FOR RANDOM AS #3 LEN = 32\n"
    +        "OPEN \"i\" FOR BINARY AS #4\n"
    +        "OPEN \"j\" FOR INPUT AS #5\n"
    +    );
    +
    +    expectCompileError("OPEN with bad mode rejected",
    +        "OPEN \"f\" FOR PRINT AS #1\n",
    +        "Expected INPUT, OUTPUT, APPEND, RANDOM, or BINARY after FOR"
    +    );
    +
    +    expectCompileError("OPTION BASE without literal rejected",
    +        "DIM x AS INTEGER\n"
    +        "OPTION BASE x\n",
    +        "Expected 0 or 1 after OPTION BASE"
    +    );
    +
    +    expectCompileError("OPTION BASE out of range rejected",
    +        "OPTION BASE 2\n",
    +        "OPTION BASE must be 0 or 1"
    +    );
    +
    +    expectCompileError("OPTION COMPARE with bad mode rejected",
    +        "OPTION COMPARE FOO\n",
    +        "Expected BINARY or TEXT after OPTION COMPARE"
    +    );
    +
    +    expectCompileError("OPTION with bad selector rejected",
    +        "OPTION FOO\n",
    +        "Expected BASE, COMPARE, or EXPLICIT after OPTION"
    +    );
    +
    +    runProgram("OPTION COMPARE BINARY",
    +        "OPTION COMPARE BINARY\n"
    +        "PRINT \"a\" = \"A\"\n",
    +        NULL
    +    );
    +
    +    expectCompileError("Required parameter after OPTIONAL rejected",
    +        "SUB S(OPTIONAL a, b)\n"
    +        "END SUB\n",
    +        "Required parameter cannot follow Optional parameter"
    +    );
    +
    +    expectCompileError("Non-identifier parameter rejected",
    +        "SUB S(1)\n"
    +        "END SUB\n",
    +        "parameter name"
    +    );
    +
    +    expectCompileError("Duplicate parameter rejected",
    +        "SUB S(a, a)\n"
    +        "END SUB\n",
    +        "Duplicate parameter name 'a'"
    +    );
    +
    +    expectCompileError("Unknown App member rejected",
    +        "PRINT App.Foo\n",
    +        "Expected 'Path', 'Config', or 'Data' after 'App.'"
    +    );
    +
    +    compileOnly("App path members",
    +        "DIM p$\n"
    +        "p$ = App.Path\n"
    +        "p$ = App.Config\n"
    +        "p$ = App.Data\n"
    +    );
    +
    +    expectCompileError("SEEK function without parentheses rejected",
    +        "DIM x AS LONG\n"
    +        "x = SEEK\n",
    +        "SEEK requires parentheses when used as a function"
    +    );
    +
    +    compileOnly("Dir function forms",
    +        "DIM s$\n"
    +        "s$ = Dir$(\"*.x\")\n"
    +        "s$ = Dir$()\n"
    +        "s$ = Dir$\n"
    +    );
    +
    +    compileOnly("InputBox expression defaults",
    +        "DIM s$\n"
    +        "s$ = InputBox$(\"p\")\n"
    +        "s$ = InputBox$(\"p\", \"t\")\n"
    +        "s$ = InputBox$(\"p\", \"t\", \"d\")\n"
    +    );
    +
    +    expectCompileError("LBOUND with non-constant dimension rejected",
    +        "DIM a(5) AS INTEGER\n"
    +        "DIM d AS INTEGER\n"
    +        "PRINT LBOUND(a, d)\n",
    +        "LBOUND dimension must be a constant integer"
    +    );
    +
    +    expectCompileError("UBOUND with non-constant dimension rejected",
    +        "DIM a(5) AS INTEGER\n"
    +        "DIM d AS INTEGER\n"
    +        "PRINT UBOUND(a, d)\n",
    +        "UBOUND dimension must be a constant integer"
    +    );
    +
    +    expectCompileError("Builtin arity rejected",
    +        "DIM s$\n"
    +        "s$ = LEFT$(\"x\")\n",
    +        "arguments, got 1"
    +    );
    +
    +    compileOnly("Control array expression read",
    +        "X = Text1(2).Caption\n"
    +    );
    +
    +    runProgram("Forward function call resolved later",
    +        "X = Sq(3)\n"
    +        "PRINT X\n"
    +        "DIM A(5)\n"
    +        "A(1) = X\n"
    +        "PRINT A(1)\n"
    +        "FUNCTION Sq(n AS INTEGER) AS INTEGER\n"
    +        "Sq = n * n\n"
    +        "END FUNCTION\n",
    +        NULL
    +    );
    +
    +    runProgram("EXIT FOR pending across DIM with bounds",
    +        "DIM i AS INTEGER\n"
    +        "FOR i = 1 TO 3\n"
    +        "IF i = 2 THEN EXIT FOR\n"
    +        "DIM B(4)\n"
    +        "B(0) = i\n"
    +        "NEXT i\n"
    +        "PRINT i\n",
    +        NULL
    +    );
    +
    +    expectCompileError("Member of expression variable rejected",
    +        "DIM v AS INTEGER\n"
    +        "X = v.(\n",
    +        "property or method name"
    +    );
    +
    +    compileOnly("Variable object property read",
    +        "DIM v AS INTEGER\n"
    +        "X = v.Caption\n"
    +    );
    +
    +    compileOnly("PRINT # trailing separators",
    +        "PRINT #1, \"a\";\n"
    +        "PRINT #1, \"b\",\n"
    +        "PRINT #1, \"c\"\n"
    +    );
    +
    +    compileOnly("PUT with expression payload",
    +        "PUT #1, , 1 + 2\n"
    +    );
    +
    +    expectCompileError("GET into a non-variable rejected",
    +        "GET #1, , 1\n",
    +        "variable name"
    +    );
    +
    +    expectCompileError("REDIM of a non-identifier rejected",
    +        "REDIM 1\n",
    +        "array variable name"
    +    );
    +
    +    expectCompileError("REDIM of a SUB rejected",
    +        "SUB S\n"
    +        "END SUB\n"
    +        "REDIM S(5)\n",
    +        "'S' is not a variable"
    +    );
    +
    +    expectCompileError("SELECT body without CASE rejected",
    +        "DIM x AS INTEGER\n"
    +        "SELECT CASE x\n"
    +        "PRINT 1\n"
    +        "END SELECT\n",
    +        "Expected CASE or END SELECT"
    +    );
    +
    +    runProgram("CASE IS operator forms",
    +        "DIM x AS INTEGER\n"
    +        "x = 5\n"
    +        "SELECT CASE x\n"
    +        "CASE IS > 4\n"
    +        "PRINT \"gt\"\n"
    +        "END SELECT\n"
    +        "SELECT CASE x\n"
    +        "CASE IS <= 5\n"
    +        "PRINT \"le\"\n"
    +        "END SELECT\n"
    +        "SELECT CASE x\n"
    +        "CASE IS >= 5\n"
    +        "PRINT \"ge\"\n"
    +        "END SELECT\n"
    +        "SELECT CASE x\n"
    +        "CASE IS = 5\n"
    +        "PRINT \"eq\"\n"
    +        "END SELECT\n"
    +        "SELECT CASE x\n"
    +        "CASE IS <> 4\n"
    +        "PRINT \"ne\"\n"
    +        "END SELECT\n",
    +        NULL
    +    );
    +
    +    expectCompileError("CASE IS without operator rejected",
    +        "DIM x AS INTEGER\n"
    +        "SELECT CASE x\n"
    +        "CASE IS PRINT\n"
    +        "END SELECT\n",
    +        "Expected comparison operator after IS"
    +    );
    +
    +    compileOnly("SELECT bodies containing END",
    +        "DIM x AS INTEGER\n"
    +        "SELECT CASE x\n"
    +        "CASE 1\n"
    +        "END\n"
    +        "CASE ELSE\n"
    +        "END\n"
    +        "END SELECT\n"
    +    );
    +
    +    expectCompileError("SELECT without END SELECT rejected",
    +        "DIM x AS INTEGER\n"
    +        "SELECT CASE x\n"
    +        "CASE 1\n"
    +        "PRINT 1\n",
    +        "Expected END SELECT"
    +    );
    +
    +    runProgram("GOTO out of SELECT CASE",
    +        "DIM x AS INTEGER\n"
    +        "x = 1\n"
    +        "SELECT CASE x\n"
    +        "CASE 1\n"
    +        "GOTO done\n"
    +        "CASE 2\n"
    +        "PRINT \"no\"\n"
    +        "END SELECT\n"
    +        "PRINT \"skipped\"\n"
    +        "done:\n"
    +        "PRINT \"d\"\n",
    +        "d\n"
    +    );
    +
    +    compileOnly("Show modal variants",
    +        "CONST M = 1\n"
    +        "Form1.Show\n"
    +        "Form1.Show 1\n"
    +        "Form1.Show TRUE\n"
    +        "Form1.Show FALSE\n"
    +        "Form1.Show M\n"
    +    );
    +
    +    runProgram("RANDOMIZE forms",
    +        "RANDOMIZE TIMER\n"
    +        "RANDOMIZE 42\n"
    +        "PRINT \"ok\"\n",
    +        "ok\n"
    +    );
    +
    +    compileOnly("CreateForm statement",
    +        "CREATEFORM(\"F\", 100, 100)\n"
    +    );
    +
    +    compileOnly("Object statement forms",
    +        "DIM v AS INTEGER\n"
    +        "SET v = Nothing\n"
    +        "Load Form1\n"
    +        "Unload Form1\n"
    +        "Unload Me\n"
    +        "InputBox$ \"p\"\n"
    +        "InputBox$ \"p\", \"t\"\n"
    +        "InputBox$ \"p\", \"t\", \"d\"\n"
    +    );
    +
    +    expectCompileError("SET of a non-variable rejected",
    +        "SET 1 = 2\n",
    +        "variable name"
    +    );
    +
    +    expectCompileError("Load of a non-identifier rejected",
    +        "Load 1\n",
    +        "form name"
    +    );
    +
    +    expectCompileError("Unload of a non-identifier rejected",
    +        "Unload 1\n",
    +        "form name"
    +    );
    +
    +    compileOnly("Me statement forms",
    +        "Me.Show\n"
    +        "Me.Show 1\n"
    +        "Me.Hide\n"
    +        "Me.Caption = \"x\"\n"
    +        "Me.Text1.Text = \"y\"\n"
    +        "Me.Text1(1).Text = \"z\"\n"
    +        "Me.Refresh\n"
    +    );
    +
    +    expectCompileError("Me without dot rejected",
    +        "Me PRINT\n",
    +        "'.' after Me"
    +    );
    +
    +    expectCompileError("Me without dot after Me rejected",
    +        "Me.5\n",
    +        "'.' after Me"
    +    );
    +
    +    expectCompileError("Me with bad member rejected",
    +        "Me.(\n",
    +        "method or member name after Me."
    +    );
    +
    +    expectCompileError("Me control array with bad property rejected",
    +        "Me.C1(1).( = 1\n",
    +        "property name"
    +    );
    +
    +    runProgram("LET assignment",
    +        "LET X = 5\n"
    +        "PRINT X\n",
    +        NULL
    +    );
    +
    +    expectCompileError("LET of a non-variable rejected",
    +        "LET 1 = 2\n",
    +        "variable name after LET"
    +    );
    +
    +    expectCompileError("Label reusing a variable name rejected",
    +        "DIM foo AS INTEGER\n"
    +        "foo:\n"
    +        "PRINT 1\n",
    +        "Name 'foo' already used"
    +    );
    +
    +    runProgram("REM statement",
    +        "REM this is a comment\n"
    +        "PRINT 2\n",
    +        NULL
    +    );
    +
    +    expectCompileError("Stray token rejected",
    +        "PRINT 1\n"
    +        ")\n",
    +        "Unexpected token"
    +    );
    +
    +    expectCompileError("STATIC at module level rejected",
    +        "STATIC x AS INTEGER\n",
    +        "STATIC is only valid inside SUB or FUNCTION"
    +    );
    +
    +    expectCompileError("STATIC without name rejected",
    +        "SUB S\n"
    +        "STATIC 1\n"
    +        "END SUB\n",
    +        "variable name"
    +    );
    +
    +    expectCompileError("STATIC of a user type rejected",
    +        "TYPE T\n"
    +        "x AS INTEGER\n"
    +        "END TYPE\n"
    +        "SUB S\n"
    +        "STATIC v AS T\n"
    +        "END SUB\n",
    +        "STATIC of a user type is not supported"
    +    );
    +
    +    expectCompileError("Duplicate STATIC rejected",
    +        "SUB S\n"
    +        "STATIC x AS INTEGER\n"
    +        "STATIC x AS INTEGER\n"
    +        "END SUB\n",
    +        "Symbol table full or duplicate STATIC variable"
    +    );
    +
    +    expectCompileError("STATIC shadowing a local rejected",
    +        "SUB S\n"
    +        "DIM x AS INTEGER\n"
    +        "STATIC x AS INTEGER\n"
    +        "END SUB\n",
    +        "Symbol table full or duplicate variable name"
    +    );
    +
    +    expectCompileError("SUB without name rejected",
    +        "SUB 1\n",
    +        "subroutine name"
    +    );
    +
    +    expectCompileError("SUB clashing with a variable rejected",
    +        "DIM S AS INTEGER\n"
    +        "SUB S\n"
    +        "END SUB\n",
    +        "already declared"
    +    );
    +
    +    expectCompileError("SWAP of a non-variable rejected",
    +        "DIM x AS INTEGER\n"
    +        "SWAP 1 + (2), x\n",
    +        "variable name"
    +    );
    +
    +    expectCompileError("SWAP indexing a scalar rejected",
    +        "DIM x AS INTEGER\n"
    +        "DIM y AS INTEGER\n"
    +        "SWAP x(1), y\n",
    +        "not an array"
    +    );
    +
    +    runProgram("SWAP of array elements",
    +        "DIM a(3) AS INTEGER\n"
    +        "DIM b(3) AS INTEGER\n"
    +        "a(1) = 5\n"
    +        "b(2) = 9\n"
    +        "SWAP a(1), b(2)\n"
    +        "PRINT a(1)\n"
    +        "PRINT b(2)\n",
    +        NULL
    +    );
    +
    +    expectCompileError("TYPE without name rejected",
    +        "TYPE 1\n",
    +        "type name"
    +    );
    +
    +    expectCompileError("Duplicate TYPE rejected",
    +        "TYPE T\n"
    +        "x AS INTEGER\n"
    +        "END TYPE\n"
    +        "TYPE T\n"
    +        "y AS INTEGER\n"
    +        "END TYPE\n",
    +        "Symbol table full or duplicate TYPE name"
    +    );
    +
    +    expectCompileError("TYPE with bad field line rejected",
    +        "TYPE T\n"
    +        "1\n"
    +        "END TYPE\n",
    +        "field name or END TYPE"
    +    );
    +
    +    expectCompileError("Error inside WHILE body surfaces",
    +        "WHILE 1\n"
    +        "PRINT )\n"
    +        "WEND\n",
    +        "Line"
    +    );
    +
    +    expectCompileError("WRITE without channel rejected",
    +        "WRITE 1\n",
    +        "Expected # after WRITE"
    +    );
    +
    +    runProgram("BOOLEAN variable",
    +        "DIM b AS BOOLEAN\n"
    +        "b = TRUE\n"
    +        "PRINT b\n",
    +        NULL
    +    );
    +
    +    expectCompileError("Unknown type name rejected",
    +        "DIM q AS Bogus\n",
    +        "Expected type name"
    +    );
    +
    +    expectCompileError("Assignment to variable without operator rejected",
    +        "DIM X AS INTEGER\n"
    +        "X 5\n",
    +        "'=' or '('"
    +    );
    +
    +    expectCompileError("Two statements without separator rejected",
    +        "A = 1 B = 2\n",
    +        "end of statement"
    +    );
    +
    +    expectCompileError("MID$ statement on a non-variable rejected",
    +        "MID$(1, 1) = \"x\"\n",
    +        "string variable name"
    +    );
    +
    +    runProgram("MID$ statement default length",
    +        "DIM s$\n"
    +        "s$ = \"hello\"\n"
    +        "MID$(s$, 2) = \"EL\"\n"
    +        "PRINT s$\n",
    +        "hELlo\n"
    +    );
    +
    +    expectCompileError("Member store with bad name rejected",
    +        "Abc.( = 1\n",
    +        "property or method name"
    +    );
    +
    +    expectCompileError("Three-part member store with bad name rejected",
    +        "F1.C1.( = 1\n",
    +        "property or method name"
    +    );
    +
    +    compileOnly("Var-held form control property store",
    +        "DIM v AS INTEGER\n"
    +        "v.C1.Text = \"x\"\n"
    +    );
    +
    +    compileOnly("Method statement forms",
    +        "DIM v AS INTEGER\n"
    +        "v.DoThing\n"
    +        "F1.C1.AddItem \"x\"\n"
    +        "C1(2).Text = \"x\"\n"
    +        "C1(3).DoIt\n"
    +    );
    +
    +    // ---- Coverage completion: control validator hooks ----
    +
    +    expectValidatorParse("Validator accepts known property",
    +        "Text1.Text = \"x\"\n",
    +        NULL
    +    );
    +
    +    expectValidatorParse("Validator rejects property store",
    +        "Text1.BadProp = 1\n",
    +        "Unknown property 'Text1.BadProp'"
    +    );
    +
    +    expectValidatorParse("Validator rejects method statement",
    +        "Text1.BadMethod\n",
    +        "Unknown method 'Text1.BadMethod'"
    +    );
    +
    +    expectValidatorParse("Validator rejects property read",
    +        "X = Text1.BadProp\n",
    +        "Unknown property 'Text1.BadProp'"
    +    );
    +
    +    expectValidatorParse("Validator rejects method call expression",
    +        "X = Text1.BadMethod(1)\n",
    +        "Unknown method 'Text1.BadMethod'"
    +    );
    +
    +    testCompilerLimits();
    +
         printf("All tests complete.  Failures: %d\n", (int)gFailCount);
         return gFailCount > 0 ? 1 : 0;
     }
    diff --git a/src/apps/kpunch/dvxbasic/test_quick.c b/src/apps/kpunch/dvxbasic/test_quick.c
    deleted file mode 100644
    index feb47af..0000000
    --- a/src/apps/kpunch/dvxbasic/test_quick.c
    +++ /dev/null
    @@ -1,85 +0,0 @@
    -// The MIT License (MIT)
    -//
    -// Copyright (C) 2026 Scott Duensing
    -//
    -// Permission is hereby granted, free of charge, to any person obtaining a copy
    -// of this software and associated documentation files (the "Software"), to
    -// deal in the Software without restriction, including without limitation the
    -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
    -// sell copies of the Software, and to permit persons to whom the Software is
    -// furnished to do so, subject to the following conditions:
    -//
    -// The above copyright notice and this permission notice shall be included in
    -// all copies or substantial portions of the Software.
    -//
    -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
    -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    -// IN THE SOFTWARE.
    -
    -// test_quick.c -- Quick single-program test
    -//
    -// Build: make -C dvxbasic tests
    -
    -#include "compiler/parser.h"
    -#include "runtime/vm.h"
    -#include "runtime/values.h"
    -#include 
    -#include 
    -
    -int main(void) {
    -    const char *source = "PRINT \"Hello, World!\"\n";
    -    printf("Source: [%s]\n", source);
    -    printf("Source len: %d\n", (int)strlen(source));
    -
    -    int32_t len = (int32_t)strlen(source);
    -    BasParserT parser;
    -    basParserInit(&parser, source, len);
    -
    -    if (!basParse(&parser)) {
    -        printf("COMPILE ERROR: %s\n", parser.error);
    -        basParserFree(&parser);
    -        return 1;
    -    }
    -
    -    printf("Compiled OK (%d bytes of p-code)\n", parser.cg.codeLen);
    -
    -    // Dump p-code
    -    for (int i = 0; i < parser.cg.codeLen; i++) {
    -        printf("%02X ", parser.cg.code[i]);
    -    }
    -    printf("\n");
    -
    -    BasModuleT *mod = basParserBuildModule(&parser);
    -    basParserFree(&parser);
    -
    -    BasVmT *vm = basVmCreate();
    -    basVmLoadModule(vm, mod);
    -    vm->callStack[0].localCount = mod->globalCount > BAS_VM_MAX_LOCALS ? BAS_VM_MAX_LOCALS : mod->globalCount;
    -    vm->callDepth = 1;
    -
    -    // Step limit
    -    int steps = 0;
    -    vm->running = true;
    -
    -    while (vm->running && steps < 1000) {
    -        BasVmResultE r = basVmStep(vm);
    -        steps++;
    -
    -        if (r != BAS_VM_OK) {
    -            printf("[Result: %d after %d steps: %s]\n", r, steps, basVmGetError(vm));
    -            break;
    -        }
    -    }
    -
    -    if (steps >= 1000) {
    -        printf("[TIMEOUT after %d steps, PC=%d]\n", steps, vm->pc);
    -    }
    -
    -    basVmDestroy(vm);
    -    basModuleFree(mod);
    -    return 0;
    -}
    diff --git a/src/apps/kpunch/dvxbasic/test_suite.c b/src/apps/kpunch/dvxbasic/test_suite.c
    index b1643c8..d3468e9 100644
    --- a/src/apps/kpunch/dvxbasic/test_suite.c
    +++ b/src/apps/kpunch/dvxbasic/test_suite.c
    @@ -31,8 +31,17 @@
     // cover compile errors and runtime errors.
     
     #include "compiler/compact.h"
    +#include "compiler/lexer.h"
     #include "compiler/obfuscate.h"
    +#include "compiler/opcodes.h"
     #include "compiler/parser.h"
    +#include "basBuild.h"
    +#include "basRes.h"
    +#include "dvxPrefs.h"
    +#include "dvxRes.h"
    +
    +#include 
    +#include 
     #include "compiler/strip.h"
     #include "runtime/serialize.h"
     #include "runtime/vm.h"
    @@ -40,6 +49,7 @@
     
     #include "stb_ds_wrap.h"
     
    +#include 
     #include 
     #include 
     #include 
    @@ -59,561 +69,32 @@ typedef struct {
     } CaptureT;
     
     
    -static void captureReset(CaptureT *c) {
    -    c->buf[0] = '\0';
    -    c->len    = 0;
    -}
    -
    -
    -static void captureAppend(CaptureT *c, const char *s) {
    -    int32_t n = (int32_t)strlen(s);
    -
    -    if (c->len + n >= CAPTURE_MAX - 1) {
    -        n = CAPTURE_MAX - 1 - c->len;
    -    }
    -
    -    if (n > 0) {
    -        memcpy(c->buf + c->len, s, n);
    -        c->len += n;
    -        c->buf[c->len] = '\0';
    -    }
    -}
    -
    -
    -static void capturePrint(void *ctx, const char *text, bool newline) {
    -    CaptureT *c = (CaptureT *)ctx;
    -
    -    if (text) {
    -        captureAppend(c, text);
    -    }
    -
    -    if (newline) {
    -        captureAppend(c, "\n");
    -    }
    -}
    -
    -
     // ============================================================
    -// Test driver
    +// Recording UI stubs
     // ============================================================
    -
    -static int32_t sPassCount = 0;
    -static int32_t sFailCount = 0;
    -
    -
    -static void reportPass(const char *name) {
    -    printf("PASS  %s\n", name);
    -    sPassCount++;
    -}
    -
    -
    -static void reportFail(const char *name, const char *detail) {
    -    printf("FAIL  %s\n", name);
    -
    -    if (detail && detail[0]) {
    -        printf("      %s\n", detail);
    -    }
    -
    -    sFailCount++;
    -}
    -
    -
    -static void reportFailDiff(const char *name, const char *expected, const char *got) {
    -    printf("FAIL  %s\n", name);
    -    printf("      expected: [%s]\n", expected);
    -    printf("      got:      [%s]\n", got);
    -    sFailCount++;
    -}
    -
    -
    -// Set up callStack[0] as the implicit main frame for module-level code.
    -static void primeMainFrame(BasVmT *vm, const BasModuleT *mod) {
    -    vm->callStack[0].localCount = mod->globalCount > BAS_VM_MAX_LOCALS ? BAS_VM_MAX_LOCALS : mod->globalCount;
    -    vm->callDepth               = 1;
    -}
    -
    -
    -// Compile + run.  On success captures PRINT output into *out.  Returns
    -// 0 on success, negative on compile error, positive for runtime errors.
    -// Runtime error text goes into outErr if provided.
    -static int32_t runAndCapture(const char *source, char *out, int32_t outSize, char *outErr, int32_t outErrSize) {
    -    int32_t len = (int32_t)strlen(source);
    -
    -    BasParserT parser;
    -    basParserInit(&parser, source, len);
    -
    -    if (!basParse(&parser)) {
    -        if (outErr) {
    -            snprintf(outErr, outErrSize, "%s", parser.error);
    -        }
    -
    -        basParserFree(&parser);
    -        return -1;
    -    }
    -
    -    BasModuleT *mod = basParserBuildModule(&parser);
    -    basParserFree(&parser);
    -
    -    if (!mod) {
    -        if (outErr) {
    -            snprintf(outErr, outErrSize, "module build failed");
    -        }
    -
    -        return -2;
    -    }
    -
    -    BasVmT *vm = basVmCreate();
    -    basVmLoadModule(vm, mod);
    -
    -    CaptureT cap;
    -    captureReset(&cap);
    -    basVmSetPrintCallback(vm, capturePrint, &cap);
    -
    -    primeMainFrame(vm, mod);
    -
    -    BasVmResultE result = basVmRun(vm);
    -
    -    int32_t rc = 0;
    -
    -    if (result != BAS_VM_HALTED && result != BAS_VM_OK) {
    -        if (outErr) {
    -            snprintf(outErr, outErrSize, "%s", basVmGetError(vm));
    -        }
    -
    -        rc = (int32_t)result;
    -    }
    -
    -    if (out && outSize > 0) {
    -        snprintf(out, outSize, "%s", cap.buf);
    -    }
    -
    -    basVmDestroy(vm);
    -    basModuleFree(mod);
    -    return rc;
    -}
    -
    -
    -// TEST_EQ -- compile, run, expect captured output to match
    -static void testEq(const char *name, const char *source, const char *expected) {
    -    char out[CAPTURE_MAX];
    -    char err[256] = "";
    -
    -    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));
    -
    -    if (rc != 0) {
    -        char detail[512];
    -        snprintf(detail, sizeof(detail), "rc=%d err=%s", (int)rc, err);
    -        reportFail(name, detail);
    -        return;
    -    }
    -
    -    if (strcmp(out, expected) != 0) {
    -        reportFailDiff(name, expected, out);
    -        return;
    -    }
    -
    -    reportPass(name);
    -}
    -
    -
    -// TEST_COMPILE_ERROR -- expect compilation to fail; substring of the
    -// error message must match.  Pass an empty needle to accept any error.
    -static void testCompileError(const char *name, const char *source, const char *needle) {
    -    char out[CAPTURE_MAX];
    -    char err[256] = "";
    -
    -    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));
    -
    -    if (rc != -1) {
    -        char detail[256];
    -        snprintf(detail, sizeof(detail), "expected compile error; got rc=%d output=[%s]", (int)rc, out);
    -        reportFail(name, detail);
    -        return;
    -    }
    -
    -    if (needle && needle[0] && strstr(err, needle) == NULL) {
    -        char detail[512];
    -        snprintf(detail, sizeof(detail), "error [%s] did not contain [%s]", err, needle);
    -        reportFail(name, detail);
    -        return;
    -    }
    -
    -    reportPass(name);
    -}
    -
    -
    -// runSubAndCapture -- compile, then invoke a specific named SUB via
    -// basVmCallSub.  Mirrors what fireCtrlEvent does for event handlers.
    -// Returns 0 if the SUB returned normally, 1 if it returned false
    -// (unhandled error), -1 on compile/load error.  Captured PRINT output
    -// goes into out; runtime error text (if any) goes into outErr.
    -static int32_t runSubAndCapture(const char *source, const char *subName,
    -                                char *out, int32_t outSize,
    -                                char *outErr, int32_t outErrSize) {
    -    int32_t len = (int32_t)strlen(source);
    -
    -    BasParserT parser;
    -    basParserInit(&parser, source, len);
    -
    -    if (!basParse(&parser)) {
    -        if (outErr) {
    -            snprintf(outErr, outErrSize, "%s", parser.error);
    -        }
    -
    -        basParserFree(&parser);
    -        return -1;
    -    }
    -
    -    BasModuleT *mod = basParserBuildModule(&parser);
    -    basParserFree(&parser);
    -
    -    if (!mod) {
    -        if (outErr) {
    -            snprintf(outErr, outErrSize, "module build failed");
    -        }
    -
    -        return -1;
    -    }
    -
    -    BasVmT *vm = basVmCreate();
    -    basVmLoadModule(vm, mod);
    -
    -    CaptureT cap;
    -    captureReset(&cap);
    -    basVmSetPrintCallback(vm, capturePrint, &cap);
    -
    -    primeMainFrame(vm, mod);
    -
    -    // Execute module-level code first (to initialize globals).  A
    -    // failure here (e.g. an error during global init) must be reported,
    -    // not swallowed -- otherwise the SUB runs against half-built state
    -    // and the test passes or fails for the wrong reason.
    -    BasVmResultE initResult = basVmRun(vm);
    -
    -    if (initResult != BAS_VM_HALTED && initResult != BAS_VM_OK) {
    -        if (outErr) {
    -            snprintf(outErr, outErrSize, "module init error: %s", basVmGetError(vm));
    -        }
    -
    -        basVmDestroy(vm);
    -        basModuleFree(mod);
    -        return -1;
    -    }
    -
    -    // Find the target SUB by name
    -    int32_t subAddr = -1;
    -
    -    for (int32_t i = 0; i < mod->procCount; i++) {
    -        if (strcasecmp(mod->procs[i].name, subName) == 0) {
    -            subAddr = mod->procs[i].codeAddr;
    -            break;
    -        }
    -    }
    -
    -    int32_t rc = 0;
    -
    -    if (subAddr < 0) {
    -        if (outErr) {
    -            snprintf(outErr, outErrSize, "SUB '%s' not found", subName);
    -        }
    -        rc = -1;
    -    } else {
    -        // Clear error state before the call so we see only this call's errors
    -        vm->errorNumber    = 0;
    -        vm->errorMsg[0]    = '\0';
    -        vm->inErrorHandler = false;
    -
    -        bool ok = basVmCallSub(vm, subAddr);
    -        rc = ok ? 0 : 1;
    -
    -        if (!ok && outErr) {
    -            snprintf(outErr, outErrSize, "%s", basVmGetError(vm));
    -        }
    -    }
    -
    -    if (out && outSize > 0) {
    -        snprintf(out, outSize, "%s", cap.buf);
    -    }
    -
    -    basVmDestroy(vm);
    -    basModuleFree(mod);
    -    return rc;
    -}
    -
    -
    -// TEST_SUB_EQ -- compile, run module-level code, then invoke a named
    -// SUB through basVmCallSub (the path used by event handlers), and
    -// compare captured output.
    -static void testSubEq(const char *name, const char *source, const char *subName, const char *expected) {
    -    char out[CAPTURE_MAX];
    -    char err[256] = "";
    -
    -    int32_t rc = runSubAndCapture(source, subName, out, sizeof(out), err, sizeof(err));
    -
    -    if (rc < 0) {
    -        char detail[512];
    -        snprintf(detail, sizeof(detail), "setup error: %s", err);
    -        reportFail(name, detail);
    -        return;
    -    }
    -
    -    if (rc != 0) {
    -        char detail[512];
    -        snprintf(detail, sizeof(detail), "SUB returned false (unhandled error): %s", err);
    -        reportFail(name, detail);
    -        return;
    -    }
    -
    -    if (strcmp(out, expected) != 0) {
    -        reportFailDiff(name, expected, out);
    -        return;
    -    }
    -
    -    reportPass(name);
    -}
    +//
    +// Every form/control callback prints what it was asked into the test's
    +// capture buffer, so a test can assert on the exact object chain the
    +// compiler emitted (form lookup, control lookup, property, method).
    +// Refs are the text of their own path; a NULL form ref is "Me".
    +
    +#define UI_STUB_REF_LEN    64
    +#define UI_STUB_REF_COUNT  16
    +
    +static CaptureT *sUiCap = NULL;
    +static char      sUiRefs[UI_STUB_REF_COUNT][UI_STUB_REF_LEN];
    +static int32_t   sUiRefNext = 0;
     
     
     #define TEST_SUB_EQ(n, s, sub, e)  testSubEq((n), (s), (sub), (e))
     
     
    -// TEST_RUNTIME_ERROR -- expect runtime error; optional substring match
    -// on the error message.
    -static void testRuntimeError(const char *name, const char *source, const char *needle) {
    -    char out[CAPTURE_MAX];
    -    char err[256] = "";
    -
    -    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));
    -
    -    if (rc <= 0) {
    -        char detail[256];
    -        snprintf(detail, sizeof(detail), "expected runtime error; got rc=%d output=[%s]", (int)rc, out);
    -        reportFail(name, detail);
    -        return;
    -    }
    -
    -    if (needle && needle[0] && strstr(err, needle) == NULL) {
    -        char detail[512];
    -        snprintf(detail, sizeof(detail), "error [%s] did not contain [%s]", err, needle);
    -        reportFail(name, detail);
    -        return;
    -    }
    -
    -    reportPass(name);
    -}
    -
    -
     #define TEST_EQ(n, s, e)             testEq((n), (s), (e))
    +#define TEST_UI_EQ(n, s, e)          testUiEq((n), (s), (e))
     #define TEST_COMPILE_ERROR(n, s, e)  testCompileError((n), (s), (e))
     #define TEST_RUNTIME_ERROR(n, s, e)  testRuntimeError((n), (s), (e))
     
     
    -// A breakpoint must pause the program even when ON ERROR GOTO is active:
    -// the debugger pause is not a trappable error.
    -static void testBreakpointNotTrapped(const char *name) {
    -    const char *source =
    -        "ON ERROR GOTO h\n"
    -        "PRINT \"a\"\n"
    -        "PRINT \"b\"\n"
    -        "END\n"
    -        "h:\n"
    -        "PRINT \"HANDLER\"\n"
    -        "RESUME NEXT\n";
    -    int32_t    breakLine = 3;
    -    BasParserT parser;
    -
    -    basParserInit(&parser, source, (int32_t)strlen(source));
    -
    -    if (!basParse(&parser)) {
    -        reportFail(name, parser.error);
    -        basParserFree(&parser);
    -        return;
    -    }
    -
    -    BasModuleT *mod = basParserBuildModule(&parser);
    -    basParserFree(&parser);
    -
    -    BasVmT  *vm = basVmCreate();
    -    CaptureT cap;
    -
    -    captureReset(&cap);
    -    basVmLoadModule(vm, mod);
    -    basVmSetPrintCallback(vm, capturePrint, &cap);
    -    basVmSetBreakpoints(vm, &breakLine, 1);
    -
    -    BasVmResultE first = basVmRun(vm);
    -
    -    vm->running = true;
    -
    -    BasVmResultE second = basVmRun(vm);
    -
    -    if (first != BAS_VM_BREAKPOINT || second != BAS_VM_HALTED || strcmp(cap.buf, "a\nb\n") != 0) {
    -        char detail[512];
    -        snprintf(detail, sizeof(detail), "first=%d second=%d out=[%s]", (int)first, (int)second, cap.buf);
    -        reportFail(name, detail);
    -    } else {
    -        reportPass(name);
    -    }
    -
    -    basVmDestroy(vm);
    -    basModuleFree(mod);
    -}
    -
    -
    -// Full release-build payload path: compile, strip, obfuscate against a
    -// .frm, compact, serialize, deserialize, run.  Checks that a SetEvent
    -// handler name survives obfuscation, that a control named after a
    -// property key ("Text") is left alone in both the .frm and the constant
    -// pool, and that the round-tripped module still runs.
    -static void testReleaseRoundTrip(const char *name) {
    -    const char *source =
    -        "SUB BtnOK_Click\n"
    -        "  PRINT \"clicked\"\n"
    -        "END SUB\n"
    -        "SUB Text_Change\n"
    -        "END SUB\n"
    -        "DIM h AS STRING\n"
    -        "h = \"BtnOK_Click\"\n"
    -        "PRINT \"Text\"\n"
    -        "PRINT h\n";
    -    const char *frm =
    -        "Begin Form Form1\n"
    -        "Caption = \"Demo\"\n"
    -        "Begin CommandButton BtnOK\n"
    -        "Caption = \"OK\"\n"
    -        "End\n"
    -        "Begin TextBox Text\n"
    -        "Text = \"abc\"\n"
    -        "End\n"
    -        "Begin DBGrid Grid1\n"
    -        "DataSource = \"BtnOK\"\n"
    -        "End\n"
    -        "End\n"
    -        "Sub Form_Load ()\nEnd Sub\n";
    -    BasParserT parser;
    -
    -    basParserInit(&parser, source, (int32_t)strlen(source));
    -
    -    if (!basParse(&parser)) {
    -        reportFail(name, parser.error);
    -        basParserFree(&parser);
    -        return;
    -    }
    -
    -    BasModuleT *mod = basParserBuildModule(&parser);
    -    basParserFree(&parser);
    -
    -    basStripModule(mod);
    -
    -    const char *frmTexts[1] = { frm };
    -    int32_t     frmLens[1]  = { (int32_t)strlen(frm) };
    -    BasObfFrmT  obf[1];
    -
    -    basObfuscateNames(mod, frmTexts, frmLens, 1, obf);
    -    basCompactBytecode(mod);
    -
    -    char detail[512] = "";
    -    bool ok          = true;
    -
    -    // The button handler was renamed (BtnOK -> C?) and the constant that
    -    // names it as a SetEvent target followed the rename.
    -    const BasProcEntryT *btnProc  = NULL;
    -    const BasProcEntryT *textProc = basModuleFindProc(mod, "Text_Change");
    -
    -    for (int32_t i = 0; i < mod->procCount; i++) {
    -        if (strstr(mod->procs[i].name, "_Click")) {
    -            btnProc = &mod->procs[i];
    -        }
    -    }
    -
    -    if (!btnProc || strncmp(btnProc->name, "BtnOK", 5) == 0) {
    -        snprintf(detail, sizeof(detail), "BtnOK_Click was not obfuscated");
    -        ok = false;
    -    } else if (!basModuleFindProc(mod, btnProc->name)) {
    -        snprintf(detail, sizeof(detail), "renamed handler not findable");
    -        ok = false;
    -    } else if (!textProc) {
    -        snprintf(detail, sizeof(detail), "Text_Change must keep its name (Text is a property key)");
    -        ok = false;
    -    }
    -
    -    bool sawHandlerConst = false;
    -    bool sawTextConst    = false;
    -
    -    for (int32_t i = 0; ok && i < mod->constCount; i++) {
    -        if (btnProc && strcmp(mod->constants[i]->data, btnProc->name) == 0) {
    -            sawHandlerConst = true;
    -        }
    -
    -        if (strcmp(mod->constants[i]->data, "Text") == 0) {
    -            sawTextConst = true;
    -        }
    -    }
    -
    -    if (ok && (!sawHandlerConst || !sawTextConst)) {
    -        snprintf(detail, sizeof(detail), "constants: handler=%d text=%d", (int)sawHandlerConst, (int)sawTextConst);
    -        ok = false;
    -    }
    -
    -    // The .frm keeps the type token and the property key, renames the
    -    // button both in its Begin line and where a value names it, and drops
    -    // the trailing code section.
    -    if (ok && obf[0].data) {
    -        const char *text = (const char *)obf[0].data;
    -
    -        if (!strstr(text, "Begin TextBox Text\n") || !strstr(text, "Text = \"abc\"") || strstr(text, "BtnOK") || strstr(text, "Form_Load") || !strstr(text, "DataSource = \"C")) {
    -            snprintf(detail, sizeof(detail), "frm rewrite wrong:\n%s", text);
    -            ok = false;
    -        }
    -    } else if (ok) {
    -        snprintf(detail, sizeof(detail), "no obfuscated frm produced");
    -        ok = false;
    -    }
    -
    -    // Serialize, deserialize, run.
    -    if (ok) {
    -        int32_t  len  = 0;
    -        uint8_t *data = basModuleSerialize(mod, &len);
    -        BasModuleT *back = data ? basModuleDeserialize(data, len) : NULL;
    -
    -        free(data);
    -
    -        if (!back) {
    -            snprintf(detail, sizeof(detail), "serialize round trip failed");
    -            ok = false;
    -        } else {
    -            BasVmT  *vm = basVmCreate();
    -            CaptureT cap;
    -            char     expected[256];
    -
    -            captureReset(&cap);
    -            basVmLoadModule(vm, back);
    -            basVmSetPrintCallback(vm, capturePrint, &cap);
    -
    -            BasVmResultE rc = basVmRun(vm);
    -
    -            snprintf(expected, sizeof(expected), "Text\n%s\n", btnProc->name);
    -
    -            if (rc != BAS_VM_HALTED || strcmp(cap.buf, expected) != 0) {
    -                snprintf(detail, sizeof(detail), "rc=%d out=[%s] expected=[%s]", (int)rc, cap.buf, expected);
    -                ok = false;
    -            }
    -
    -            basVmDestroy(vm);
    -            basModuleFree(back);
    -        }
    -    }
    -
    -    free(obf[0].data);
    -    basModuleFree(mod);
    -
    -    if (ok) {
    -        reportPass(name);
    -    } else {
    -        reportFail(name, detail);
    -    }
    -}
    -
    -
     // ============================================================
     // Widget-level event-dispatch harness
     // ============================================================
    @@ -664,24 +145,362 @@ typedef struct {
     } MiniRtT;
     
     
    -static MiniFormT *miniCreateForm(MiniRtT *rt, const char *name, int32_t formVarCount) {
    -    MiniFormT *f = (MiniFormT *)calloc(1, sizeof(MiniFormT));
    +// Context for an event-dispatch test: what to build and how to fire.
    +typedef struct {
    +    const char *source;          // BASIC source
    +    const char *formName;        // "" if ctrl has no owning BEGINFORM
    +    int32_t     formVarCount;    // >0 if the module has form-scope vars
    +    const char *ctrlName;        // control to fire events on
    +    const char *ctrlType;        // widget type, e.g. "CommandButton" / "Timer"
    +    // Array of (eventName, handlerName) for SetEvent.  Terminate with NULL.
    +    const char *overrides[2 * 8];
    +    // Array of event names to fire in order.  Terminate with NULL.
    +    const char *fires[8];
    +} TestDispatchT;
     
    -    if (!f) {
    -        fprintf(stderr, "FATAL: out of memory allocating MiniFormT\n");
    -        exit(EXIT_FAILURE);
    +
    +// Helper macros to keep tests compact.  `overrides` and `fires` are
    +// comma lists terminated with NULL/NULL.
    +#define DISPATCH_OVR(...) { __VA_ARGS__, NULL, NULL }
    +#define DISPATCH_FIRE(...) { __VA_ARGS__, NULL }
    +
    +
    +// ============================================================
    +// Test cases
    +// ============================================================
    +
    +// ============================================================
    +// Hand-assembled p-code (formerly test_vm.c)
    +//
    +// Runs the VM on bytecode built without the compiler so opcode
    +// encoding and the FOR_INIT/FOR_NEXT frame logic are checked
    +// independently of codegen.
    +// ============================================================
    +
    +#define HAND_CODE_MAX 512
    +
    +// Hand-battery constants: an opcode byte no table entry claims, the
    +// byte length of a PUSH_INT16 + JMP pair (for the backward jump), the
    +// GOSUB_RET landing address, the FOR_INIT empty-range skip offset, a
    +// debug stream shorter than its count header, the serializer's fixed
    +// header offsets, and a type byte no BAS_TYPE_* value uses.
    +#define HAND_BAD_OPCODE   0x0B
    +#define HAND_PUSH_JMP_LEN 6
    +#define HAND_GOSUB_TARGET 6
    +#define HAND_FOR_SKIP     2
    +#define BAS_DBG_TINY_LEN  2
    +#define SER_OFF_VERSION   4
    +#define SER_OFF_GLOBALS   10
    +#define SER_OFF_CODELEN   14
    +#define SER_OFF_CONSTS    18
    +#define SER_OFF_DATA      22
    +#define SER_OFF_PROCS     26
    +#define SER_HEADER_LEN    30
    +#define SER_BAD_TYPE      0x63
    +
    +// Opcode-guard battery: bytes per hand sequence, the FOR-stack depth one
    +// past the VM cap, and a CALL base slot past the local-slot window.
    +#define GUARD_CODE_MAX     32
    +#define GUARD_FOR_TOO_DEEP (BAS_VM_MAX_FOR_DEPTH + 1)
    +#define GUARD_BAD_SLOT     (BAS_VM_MAX_LOCALS - 1)
    +
    +typedef struct {
    +    uint8_t code[HAND_CODE_MAX];
    +    int32_t len;
    +} HandCodeT;
    +
    +
    +// ============================================================
    +// Lexer token stream (formerly test_lex.c)
    +// ============================================================
    +
    +typedef struct {
    +    BasTokenTypeE type;
    +    const char   *text;
    +} ExpectedTokenT;
    +
    +
    +// ============================================================
    +// Compiled bytecode (formerly test_quick.c)
    +// ============================================================
    +
    +
    +static void breakpointRecordStub(void *ctx, int32_t line);
    +static void captureAppend(CaptureT *c, const char *s);
    +static void capturePrint(void *ctx, const char *text, bool newline);
    +static void captureReset(CaptureT *c);
    +static BasModuleT *compileSource(const char *name, const char *source);
    +static bool doEventsAbortStub(void *ctx);
    +static bool doEventsCountdownStub(void *ctx);
    +static bool doEventsResumeStub(void *ctx);
    +static void expectTrue(const char *name, bool ok, const char *detail);
    +static BasValueT externCallStub(void *ctx, void *funcPtr, const char *libName, const char *funcName, BasValueT *args, int32_t argc, uint8_t retType);
    +static void *externResolveStub(void *ctx, const char *libName, const char *funcName);
    +static void handEmit16(HandCodeT *h, int16_t v);
    +static void handEmit8(HandCodeT *h, uint8_t b);
    +static void handEmitU16(HandCodeT *h, uint16_t v);
    +static bool inputStub(void *ctx, const char *prompt, char *buf, int32_t bufSize);
    +static MiniCtrlT *miniAddCtrl(MiniFormT *f, const char *name, const char *typeName);
    +static MiniFormT *miniCreateForm(MiniRtT *rt, const char *name, int32_t formVarCount);
    +static const BasProcEntryT *miniFindProc(BasModuleT *mod, const char *name);
    +static void miniFireCtrlEvent(MiniRtT *rt, MiniCtrlT *ctrl, const char *eventName);
    +static MiniFormT *miniResolveOwningForm(MiniRtT *rt, const BasProcEntryT *proc);
    +static void miniSetEvent(MiniCtrlT *ctrl, const char *eventName, const char *handlerName);
    +static void primeMainFrame(BasVmT *vm, const BasModuleT *mod);
    +static void reportFail(const char *name, const char *detail);
    +static void reportFailDiff(const char *name, const char *expected, const char *got);
    +static void reportPass(const char *name);
    +static int32_t runAndCapture(const char *source, char *out, int32_t outSize, char *outErr, int32_t outErrSize);
    +static int32_t runSubAndCapture(const char *source, const char *subName, char *out, int32_t outSize, char *outErr, int32_t outErrSize);
    +static void testBasBuild(void);
    +static void testBreakpointNotTrapped(const char *name);
    +static void testBytecode(const char *name, const char *source, const uint8_t *expected, int32_t expectedLen);
    +static void testCompileError(const char *name, const char *source, const char *needle);
    +static void testDebuggerApi(void);
    +static void testDebugInfoRoundTrip(const char *name);
    +static void testDispatchEq(const char *name, const TestDispatchT *d, const char *expected);
    +static void testEq(const char *name, const char *source, const char *expected);
    +static void testExternCalls(void);
    +static void testHandAssembled(void);
    +static void testHandBattery(void);
    +static void testHandRun(const char *name, const HandCodeT *h, BasStringT **consts, int32_t constCount, int32_t localCount, const char *expected);
    +static void testHandRunError(const char *name, const HandCodeT *h, BasStringT **consts, int32_t constCount, int32_t localCount, const char *needle);
    +static void testInputCallback(const char *name);
    +static void testLexerTokens(const char *name, const char *source, const ExpectedTokenT *expected, int32_t expectedCount);
    +static void testLoadModuleRefusal(const char *name);
    +static void testOpcodeGuards(void);
    +static void testReleaseRoundTrip(const char *name);
    +static void testRuntimeError(const char *name, const char *source, const char *needle);
    +static void testSerializeCorrupt(const char *name);
    +static void testSubEq(const char *name, const char *source, const char *subName, const char *expected);
    +static void testUiEq(const char *name, const char *source, const char *expected);
    +static void testValuesApi(void);
    +static BasValueT uiStubCallMethod(void *ctx, void *ctrlRef, const char *methodName, BasValueT *args, int32_t argc);
    +static void *uiStubCreateCtrl(void *ctx, void *formRef, const char *typeName, const char *ctrlName);
    +static void *uiStubCreateCtrlEx(void *ctx, void *formRef, const char *typeName, const char *ctrlName, void *parentRef);
    +static void *uiStubCreateForm(void *ctx, const char *formName, int32_t width, int32_t height);
    +static void *uiStubFindCtrl(void *ctx, void *formRef, const char *ctrlName);
    +static void *uiStubFindCtrlIdx(void *ctx, void *formRef, const char *ctrlName, int32_t index);
    +static const char *uiStubFormName(void *formRef);
    +static BasValueT uiStubGetProp(void *ctx, void *ctrlRef, const char *propName);
    +static void uiStubHideForm(void *ctx, void *formRef);
    +static BasStringT *uiStubInputBox(void *ctx, const char *prompt, const char *title, const char *defaultText);
    +static void *uiStubLoadForm(void *ctx, const char *formName);
    +static int32_t uiStubMsgBox(void *ctx, const char *message, int32_t flags, const char *title);
    +static void *uiStubRef(const char *text);
    +static void uiStubRemoveCtrl(void *ctx, void *formRef, const char *ctrlName);
    +static void uiStubSetEvent(void *ctx, void *ctrlRef, const char *eventName, const char *handlerName);
    +static void uiStubSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT value);
    +static void uiStubShowForm(void *ctx, void *formRef, bool modal);
    +static void uiStubUnloadForm(void *ctx, void *formRef);
    +
    +
    +static const BasUiCallbacksT sUiStubs = {
    +    .getProp      = uiStubGetProp,
    +    .setProp      = uiStubSetProp,
    +    .callMethod   = uiStubCallMethod,
    +    .createCtrl   = uiStubCreateCtrl,
    +    .createCtrlEx = uiStubCreateCtrlEx,
    +    .createForm   = uiStubCreateForm,
    +    .findCtrl     = uiStubFindCtrl,
    +    .findCtrlIdx  = uiStubFindCtrlIdx,
    +    .loadForm     = uiStubLoadForm,
    +    .unloadForm   = uiStubUnloadForm,
    +    .showForm     = uiStubShowForm,
    +    .hideForm     = uiStubHideForm,
    +    .msgBox       = uiStubMsgBox,
    +    .inputBox     = uiStubInputBox,
    +    .setEvent     = uiStubSetEvent,
    +    .removeCtrl   = uiStubRemoveCtrl,
    +};
    +
    +// Installed by runAndCapture when non-NULL (see TEST_UI_EQ).
    +static const BasUiCallbacksT *sRunUi = NULL;
    +
    +
    +// ============================================================
    +// Test driver
    +// ============================================================
    +
    +static int32_t sPassCount = 0;
    +static int32_t sFailCount = 0;
    +
    +// State shared with the coverage-battery callback stubs.
    +static int32_t sBpLines[16];
    +static int32_t sBpCount        = 0;
    +static char    sExternTarget   = 0;
    +static int32_t sExternResolves = 0;
    +static char    sInputLog[128];
    +static int32_t sInputCalls     = 0;
    +
    +
    +// Records breakpoint notifications from runSubLoop for the debugger tests.
    +static void breakpointRecordStub(void *ctx, int32_t line) {
    +    (void)ctx;
    +
    +    if (sBpCount < (int32_t)(sizeof(sBpLines) / sizeof(sBpLines[0]))) {
    +        sBpLines[sBpCount++] = line;
    +    }
    +}
    +
    +
    +static void captureAppend(CaptureT *c, const char *s) {
    +    int32_t n = (int32_t)strlen(s);
    +
    +    if (c->len + n >= CAPTURE_MAX - 1) {
    +        n = CAPTURE_MAX - 1 - c->len;
         }
     
    -    snprintf(f->name, MINI_MAX_NAME, "%s", name);
    -    f->ctrls = NULL;
    +    if (n > 0) {
    +        memcpy(c->buf + c->len, s, n);
    +        c->len += n;
    +        c->buf[c->len] = '\0';
    +    }
    +}
     
    -    if (formVarCount > 0) {
    -        f->formVars     = (BasValueT *)calloc(formVarCount, sizeof(BasValueT));
    -        f->formVarCount = formVarCount;
    +
    +static void capturePrint(void *ctx, const char *text, bool newline) {
    +    CaptureT *c = (CaptureT *)ctx;
    +
    +    if (text) {
    +        captureAppend(c, text);
         }
     
    -    arrput(rt->forms, f);
    -    return f;
    +    if (newline) {
    +        captureAppend(c, "\n");
    +    }
    +}
    +
    +
    +static void captureReset(CaptureT *c) {
    +    c->buf[0] = '\0';
    +    c->len    = 0;
    +}
    +
    +
    +// Compile source to a module, reporting a failure under name on error.
    +static BasModuleT *compileSource(const char *name, const char *source) {
    +    BasParserT parser;
    +
    +    basParserInit(&parser, source, (int32_t)strlen(source));
    +
    +    if (!basParse(&parser)) {
    +        reportFail(name, parser.error);
    +        basParserFree(&parser);
    +        return NULL;
    +    }
    +
    +    BasModuleT *mod = basParserBuildModule(&parser);
    +
    +    basParserFree(&parser);
    +
    +    if (!mod) {
    +        reportFail(name, "module build failed");
    +    }
    +
    +    return mod;
    +}
    +
    +
    +// DoEvents stub that tells the VM to stop (host shutting down).
    +static bool doEventsAbortStub(void *ctx) {
    +    (void)ctx;
    +    return false;
    +}
    +
    +
    +// DoEvents stub that stays alive for *ctx calls, then reports shutdown.
    +static bool doEventsCountdownStub(void *ctx) {
    +    int32_t *remaining = (int32_t *)ctx;
    +
    +    if (*remaining <= 0) {
    +        return false;
    +    }
    +
    +    (*remaining)--;
    +    return true;
    +}
    +
    +
    +// DoEvents stub that immediately resumes a VM paused at a breakpoint.
    +static bool doEventsResumeStub(void *ctx) {
    +    BasVmT *vm = (BasVmT *)ctx;
    +
    +    vm->running = true;
    +    return true;
    +}
    +
    +
    +static void expectTrue(const char *name, bool ok, const char *detail) {
    +    if (ok) {
    +        reportPass(name);
    +    } else {
    +        reportFail(name, detail);
    +    }
    +}
    +
    +
    +// Extern-call stub: returns the int32 sum of the arguments.
    +static BasValueT externCallStub(void *ctx, void *funcPtr, const char *libName, const char *funcName, BasValueT *args, int32_t argc, uint8_t retType) {
    +    int32_t sum = 0;
    +
    +    (void)ctx;
    +    (void)funcPtr;
    +    (void)libName;
    +    (void)funcName;
    +    (void)retType;
    +
    +    for (int32_t i = 0; i < argc; i++) {
    +        sum += basValToInt32(args[i]);
    +    }
    +
    +    return basValLong(sum);
    +}
    +
    +
    +// Extern-resolve stub: everything resolves except "Missing".
    +static void *externResolveStub(void *ctx, const char *libName, const char *funcName) {
    +    (void)ctx;
    +    (void)libName;
    +    sExternResolves++;
    +
    +    if (strcasecmp(funcName, "Missing") == 0) {
    +        return NULL;
    +    }
    +
    +    return &sExternTarget;
    +}
    +
    +
    +static void handEmit16(HandCodeT *h, int16_t v) {
    +    memcpy(&h->code[h->len], &v, sizeof(v));
    +    h->len += (int32_t)sizeof(v);
    +}
    +
    +
    +static void handEmit8(HandCodeT *h, uint8_t b) {
    +    h->code[h->len++] = b;
    +}
    +
    +
    +static void handEmitU16(HandCodeT *h, uint16_t v) {
    +    handEmit16(h, (int16_t)v);
    +}
    +
    +
    +// INPUT stub: logs the prompt, answers "42" on the first call and
    +// cancels every later one.
    +static bool inputStub(void *ctx, const char *prompt, char *buf, int32_t bufSize) {
    +    (void)ctx;
    +    snprintf(sInputLog + strlen(sInputLog), sizeof(sInputLog) - strlen(sInputLog), "%s|", prompt);
    +    sInputCalls++;
    +
    +    if (sInputCalls == 1) {
    +        snprintf(buf, bufSize, "42");
    +        return true;
    +    }
    +
    +    return false;
     }
     
     
    @@ -703,20 +522,24 @@ static MiniCtrlT *miniAddCtrl(MiniFormT *f, const char *name, const char *typeNa
     }
     
     
    -static void miniSetEvent(MiniCtrlT *ctrl, const char *eventName, const char *handlerName) {
    -    // Match formrt's SetEvent: update existing or append
    -    for (int32_t i = 0; i < ctrl->overrideCount; i++) {
    -        if (strcasecmp(ctrl->overrides[i].eventName, eventName) == 0) {
    -            snprintf(ctrl->overrides[i].handlerName, MINI_MAX_NAME, "%s", handlerName);
    -            return;
    -        }
    +static MiniFormT *miniCreateForm(MiniRtT *rt, const char *name, int32_t formVarCount) {
    +    MiniFormT *f = (MiniFormT *)calloc(1, sizeof(MiniFormT));
    +
    +    if (!f) {
    +        fprintf(stderr, "FATAL: out of memory allocating MiniFormT\n");
    +        exit(EXIT_FAILURE);
         }
     
    -    if (ctrl->overrideCount < MINI_MAX_OVERRIDES) {
    -        MiniOverrideT *o = &ctrl->overrides[ctrl->overrideCount++];
    -        snprintf(o->eventName,   MINI_MAX_NAME, "%s", eventName);
    -        snprintf(o->handlerName, MINI_MAX_NAME, "%s", handlerName);
    +    snprintf(f->name, MINI_MAX_NAME, "%s", name);
    +    f->ctrls = NULL;
    +
    +    if (formVarCount > 0) {
    +        f->formVars     = (BasValueT *)calloc(formVarCount, sizeof(BasValueT));
    +        f->formVarCount = formVarCount;
         }
    +
    +    arrput(rt->forms, f);
    +    return f;
     }
     
     
    @@ -735,21 +558,6 @@ static const BasProcEntryT *miniFindProc(BasModuleT *mod, const char *name) {
     }
     
     
    -static MiniFormT *miniResolveOwningForm(MiniRtT *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;
    -}
    -
    -
     // Mirror of formrt.c's fireCtrlEvent.  Re-entrancy guard, override
     // table, form-scope vars binding -- kept in sync by copy.
     static void miniFireCtrlEvent(MiniRtT *rt, MiniCtrlT *ctrl, const char *eventName) {
    @@ -851,18 +659,772 @@ static void miniFireCtrlEvent(MiniRtT *rt, MiniCtrlT *ctrl, const char *eventNam
     }
     
     
    -// Context for an event-dispatch test: what to build and how to fire.
    -typedef struct {
    -    const char *source;          // BASIC source
    -    const char *formName;        // "" if ctrl has no owning BEGINFORM
    -    int32_t     formVarCount;    // >0 if the module has form-scope vars
    -    const char *ctrlName;        // control to fire events on
    -    const char *ctrlType;        // widget type, e.g. "CommandButton" / "Timer"
    -    // Array of (eventName, handlerName) for SetEvent.  Terminate with NULL.
    -    const char *overrides[2 * 8];
    -    // Array of event names to fire in order.  Terminate with NULL.
    -    const char *fires[8];
    -} TestDispatchT;
    +static MiniFormT *miniResolveOwningForm(MiniRtT *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;
    +}
    +
    +
    +static void miniSetEvent(MiniCtrlT *ctrl, const char *eventName, const char *handlerName) {
    +    // Match formrt's SetEvent: update existing or append
    +    for (int32_t i = 0; i < ctrl->overrideCount; i++) {
    +        if (strcasecmp(ctrl->overrides[i].eventName, eventName) == 0) {
    +            snprintf(ctrl->overrides[i].handlerName, MINI_MAX_NAME, "%s", handlerName);
    +            return;
    +        }
    +    }
    +
    +    if (ctrl->overrideCount < MINI_MAX_OVERRIDES) {
    +        MiniOverrideT *o = &ctrl->overrides[ctrl->overrideCount++];
    +        snprintf(o->eventName,   MINI_MAX_NAME, "%s", eventName);
    +        snprintf(o->handlerName, MINI_MAX_NAME, "%s", handlerName);
    +    }
    +}
    +
    +
    +// Set up callStack[0] as the implicit main frame for module-level code.
    +static void primeMainFrame(BasVmT *vm, const BasModuleT *mod) {
    +    vm->callStack[0].localCount = mod->globalCount > BAS_VM_MAX_LOCALS ? BAS_VM_MAX_LOCALS : mod->globalCount;
    +    vm->callDepth               = 1;
    +}
    +
    +
    +static void reportFail(const char *name, const char *detail) {
    +    printf("FAIL  %s\n", name);
    +
    +    if (detail && detail[0]) {
    +        printf("      %s\n", detail);
    +    }
    +
    +    sFailCount++;
    +}
    +
    +
    +static void reportFailDiff(const char *name, const char *expected, const char *got) {
    +    printf("FAIL  %s\n", name);
    +    printf("      expected: [%s]\n", expected);
    +    printf("      got:      [%s]\n", got);
    +    sFailCount++;
    +}
    +
    +
    +static void reportPass(const char *name) {
    +    printf("PASS  %s\n", name);
    +    sPassCount++;
    +}
    +
    +
    +// Compile + run.  On success captures PRINT output into *out.  Returns
    +// 0 on success, negative on compile error, positive for runtime errors.
    +// Runtime error text goes into outErr if provided.
    +static int32_t runAndCapture(const char *source, char *out, int32_t outSize, char *outErr, int32_t outErrSize) {
    +    int32_t len = (int32_t)strlen(source);
    +
    +    BasParserT parser;
    +    basParserInit(&parser, source, len);
    +
    +    if (!basParse(&parser)) {
    +        if (outErr) {
    +            snprintf(outErr, outErrSize, "%s", parser.error);
    +        }
    +
    +        basParserFree(&parser);
    +        return -1;
    +    }
    +
    +    BasModuleT *mod = basParserBuildModule(&parser);
    +    basParserFree(&parser);
    +
    +    if (!mod) {
    +        if (outErr) {
    +            snprintf(outErr, outErrSize, "module build failed");
    +        }
    +
    +        return -2;
    +    }
    +
    +    BasVmT *vm = basVmCreate();
    +    basVmLoadModule(vm, mod);
    +
    +    CaptureT cap;
    +    captureReset(&cap);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +
    +    if (sRunUi) {
    +        sUiCap = ∩
    +        basVmSetUiCallbacks(vm, sRunUi);
    +    }
    +
    +    primeMainFrame(vm, mod);
    +
    +    BasVmResultE result = basVmRun(vm);
    +
    +    sUiCap = NULL;
    +
    +    int32_t rc = 0;
    +
    +    if (result != BAS_VM_HALTED && result != BAS_VM_OK) {
    +        if (outErr) {
    +            snprintf(outErr, outErrSize, "%s", basVmGetError(vm));
    +        }
    +
    +        rc = (int32_t)result;
    +    }
    +
    +    if (out && outSize > 0) {
    +        snprintf(out, outSize, "%s", cap.buf);
    +    }
    +
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +    return rc;
    +}
    +
    +
    +// runSubAndCapture -- compile, then invoke a specific named SUB via
    +// basVmCallSub.  Mirrors what fireCtrlEvent does for event handlers.
    +// Returns 0 if the SUB returned normally, 1 if it returned false
    +// (unhandled error), -1 on compile/load error.  Captured PRINT output
    +// goes into out; runtime error text (if any) goes into outErr.
    +static int32_t runSubAndCapture(const char *source, const char *subName, char *out, int32_t outSize, char *outErr, int32_t outErrSize) {
    +    int32_t len = (int32_t)strlen(source);
    +
    +    BasParserT parser;
    +    basParserInit(&parser, source, len);
    +
    +    if (!basParse(&parser)) {
    +        if (outErr) {
    +            snprintf(outErr, outErrSize, "%s", parser.error);
    +        }
    +
    +        basParserFree(&parser);
    +        return -1;
    +    }
    +
    +    BasModuleT *mod = basParserBuildModule(&parser);
    +    basParserFree(&parser);
    +
    +    if (!mod) {
    +        if (outErr) {
    +            snprintf(outErr, outErrSize, "module build failed");
    +        }
    +
    +        return -1;
    +    }
    +
    +    BasVmT *vm = basVmCreate();
    +    basVmLoadModule(vm, mod);
    +
    +    CaptureT cap;
    +    captureReset(&cap);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +
    +    primeMainFrame(vm, mod);
    +
    +    // Execute module-level code first (to initialize globals).  A
    +    // failure here (e.g. an error during global init) must be reported,
    +    // not swallowed -- otherwise the SUB runs against half-built state
    +    // and the test passes or fails for the wrong reason.
    +    BasVmResultE initResult = basVmRun(vm);
    +
    +    if (initResult != BAS_VM_HALTED && initResult != BAS_VM_OK) {
    +        if (outErr) {
    +            snprintf(outErr, outErrSize, "module init error: %s", basVmGetError(vm));
    +        }
    +
    +        basVmDestroy(vm);
    +        basModuleFree(mod);
    +        return -1;
    +    }
    +
    +    // Find the target SUB by name
    +    int32_t subAddr = -1;
    +
    +    for (int32_t i = 0; i < mod->procCount; i++) {
    +        if (strcasecmp(mod->procs[i].name, subName) == 0) {
    +            subAddr = mod->procs[i].codeAddr;
    +            break;
    +        }
    +    }
    +
    +    int32_t rc = 0;
    +
    +    if (subAddr < 0) {
    +        if (outErr) {
    +            snprintf(outErr, outErrSize, "SUB '%s' not found", subName);
    +        }
    +        rc = -1;
    +    } else {
    +        // Clear error state before the call so we see only this call's errors
    +        vm->errorNumber    = 0;
    +        vm->errorMsg[0]    = '\0';
    +        vm->inErrorHandler = false;
    +
    +        bool ok = basVmCallSub(vm, subAddr);
    +        rc = ok ? 0 : 1;
    +
    +        if (!ok && outErr) {
    +            snprintf(outErr, outErrSize, "%s", basVmGetError(vm));
    +        }
    +    }
    +
    +    if (out && outSize > 0) {
    +        snprintf(out, outSize, "%s", cap.buf);
    +    }
    +
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +    return rc;
    +}
    +
    +
    +// A breakpoint must pause the program even when ON ERROR GOTO is active:
    +// the debugger pause is not a trappable error.
    +// Project concatenation and app building: the .dbp readers, the append
    +// helper's newline/wrapper logic, and basBuildApp's stub/resource/help
    +// pipeline including its error strings.
    +static void testBasBuild(void) {
    +    char projectDir[512];
    +    char path[512];
    +
    +    snprintf(projectDir, sizeof(projectDir), "ts_proj");
    +    mkdir(projectDir, 0777);
    +
    +    // Append helper: newline is added, forms are wrapped, line numbers
    +    // land where an IDE source map needs them.
    +    BasProjectSourceT ps;
    +    int32_t           line  = 0;
    +    int32_t           lines = 0;
    +
    +    memset(&ps, 0, sizeof(ps));
    +    expectTrue("build-append-module", basBuildAppendFile(&ps, "PRINT 1", 7, NULL, &line, &lines), ps.error);
    +    expectTrue("build-append-newline", ps.sourceLen == 8 && ps.source[7] == '\n', "missing forced newline");
    +    expectTrue("build-append-lines", line == 1 && lines == 1, "line map wrong");
    +    expectTrue("build-append-form", basBuildAppendFile(&ps, "SUB F_Load\nEND SUB\n", 19, "F", &line, &lines), ps.error);
    +    expectTrue("build-append-form-map", line == 3 && lines == 2, "form line map wrong");
    +    expectTrue("build-append-wrapped", strstr(ps.source, "BEGINFORM \"F\"\n") != NULL && strstr(ps.source, "ENDFORM\n") != NULL, "wrapper missing");
    +    basBuildFreeProject(&ps);
    +
    +    // Concat: missing file, empty project, then a working one.
    +    PrefsHandleT *prefs = prefsCreate();
    +
    +    prefsSetString(prefs, "Modules", "File0", "nosuch.bas");
    +
    +    expectTrue("build-concat-missing", !basBuildConcatProject(prefs, projectDir, &ps) && strstr(ps.error, "Cannot read") != NULL, ps.error);
    +    basBuildFreeProject(&ps);
    +    prefsClose(prefs);
    +
    +    prefs = prefsCreate();
    +    expectTrue("build-concat-empty", !basBuildConcatProject(prefs, projectDir, &ps) && strstr(ps.error, "no source files") != NULL, ps.error);
    +    basBuildFreeProject(&ps);
    +    prefsClose(prefs);
    +
    +    snprintf(path, sizeof(path), "%s/main.bas", projectDir);
    +
    +    FILE *f = fopen(path, "w");
    +
    +    fprintf(f, "PRINT \"hi\"");
    +    fclose(f);
    +    snprintf(path, sizeof(path), "%s/form1.frm", projectDir);
    +    f = fopen(path, "w");
    +    fprintf(f, "Begin Form Form1\n    Caption = \"B\"\nEnd\nSub Form1_Load\nEnd Sub\n");
    +    fclose(f);
    +    snprintf(path, sizeof(path), "%s/help.dhs", projectDir);
    +    f = fopen(path, "w");
    +    fprintf(f, ".h1 Help\n");
    +    fclose(f);
    +
    +    prefs = prefsCreate();
    +    prefsSetString(prefs, "Modules", "File0", "main.bas");
    +    prefsSetString(prefs, "Forms", "File0", "form1.frm");
    +    prefsSetBool(prefs, "Settings", "OptionExplicit", true);
    +    expectTrue("build-concat-ok", basBuildConcatProject(prefs, projectDir, &ps), ps.error);
    +    expectTrue("build-concat-explicit", ps.optionExplicit && ps.fileCount == 2 && arrlen(ps.frmPaths) == 1, "concat results wrong");
    +    prefsClose(prefs);
    +
    +    // basBuildApp needs a compiled module and a self executable carrying
    +    // a STUB resource; build a fake self with the resource API.
    +    BasModuleT *mod = compileSource("build-app-module", "PRINT \"app\"\n");
    +
    +    if (!mod) {
    +        basBuildFreeProject(&ps);
    +        return;
    +    }
    +
    +    static const char *const frms[1] = { "Begin Form Form1\n    Caption = \"B\"\nEnd\n' comment\n" };
    +    BasBuildSpecT spec;
    +
    +    memset(&spec, 0, sizeof(spec));
    +    spec.projName   = "TestApp";
    +    spec.author     = "covBasic";
    +    spec.projectDir = projectDir;
    +    spec.helpFile   = "help.dhs";
    +    spec.module     = mod;
    +    spec.frmSources = frms;
    +    spec.frmCount   = 1;
    +
    +    expectTrue("build-app-null", basBuildApp(NULL, &spec) != NULL, "NULL outPath accepted");
    +
    +    // Self with no resource block at all, then with resources but no
    +    // STUB entry: both must fail before anything is written.
    +    char selfPath[512];
    +
    +    snprintf(selfPath, sizeof(selfPath), "%s/self.bin", projectDir);
    +    f = fopen(selfPath, "w");
    +    fprintf(f, "SELF");
    +    fclose(f);
    +    spec.selfPath = selfPath;
    +
    +    const char *failure = basBuildApp("ts_out.app", &spec);
    +
    +    expectTrue("build-app-unreadable-self", failure != NULL, "resource-less self accepted");
    +
    +    static const uint8_t padBytes[] = { 0x00 };
    +
    +    expectTrue("build-app-self-pad", dvxResAppend(selfPath, "PAD", DVX_RES_BINARY, padBytes, sizeof(padBytes)) == 0, "pad append failed");
    +    failure = basBuildApp("ts_out.app", &spec);
    +    expectTrue("build-app-no-stub", failure != NULL && strstr(failure, "STUB") != NULL, failure ? failure : "no failure");
    +
    +    // Copy the fallback icon into the project so the build never depends
    +    // on the harness working directory.
    +    int32_t  iconLen  = 0;
    +    char    *iconData = platformReadFile(BAS_TEST_SRC_DIR "/noicon.bmp", &iconLen);
    +
    +    expectTrue("build-app-icon-src", iconData != NULL && iconLen > 0, "cannot read noicon.bmp");
    +
    +    if (iconData) {
    +        snprintf(path, sizeof(path), "%s/icon.bmp", projectDir);
    +        f = fopen(path, "wb");
    +        fwrite(iconData, 1, (size_t)iconLen, f);
    +        fclose(f);
    +        free(iconData);
    +    }
    +
    +
    +    // Give the self a STUB and the project icon; the build then runs the
    +    // whole pipeline: stub write, resources, icon, help copy.
    +    static const uint8_t stubBytes[] = { 0x7F, 0x44, 0x58, 0x45 };
    +
    +    expectTrue("build-app-self-stub", dvxResAppend(selfPath, "STUB", DVX_RES_BINARY, stubBytes, sizeof(stubBytes)) == 0, "stub append failed");
    +    spec.iconPath = "icon.bmp";
    +    failure = basBuildApp("ts_out.app", &spec);
    +    expectTrue("build-app-ok", failure == NULL, failure ? failure : "");
    +    expectTrue("build-app-help-copied", access("help.dhs", F_OK) == 0, "help file not copied");
    +    unlink("help.dhs");
    +
    +    // Release mode strips, obfuscates and compacts a private copy.
    +    spec.release = true;
    +    failure = basBuildApp("ts_out.app", &spec);
    +    expectTrue("build-app-release", failure == NULL, failure ? failure : "");
    +    spec.release = false;
    +
    +    // Unwritable output and a missing help file.
    +    failure = basBuildApp("/no/such/dir/x.app", &spec);
    +    expectTrue("build-app-unwritable", failure != NULL, "unwritable output accepted");
    +    spec.helpFile = "missing.dhs";
    +    failure = basBuildApp("ts_out.app", &spec);
    +    expectTrue("build-app-help-missing", failure != NULL && strstr(failure, "help") != NULL, failure ? failure : "no failure");
    +
    +    basModuleFree(mod);
    +    basBuildFreeProject(&ps);
    +    unlink("ts_out.app");
    +    snprintf(path, sizeof(path), "%s/main.bas", projectDir);
    +    unlink(path);
    +    snprintf(path, sizeof(path), "%s/form1.frm", projectDir);
    +    unlink(path);
    +    snprintf(path, sizeof(path), "%s/help.dhs", projectDir);
    +    unlink(path);
    +    snprintf(path, sizeof(path), "%s/icon.bmp", projectDir);
    +    unlink(path);
    +    unlink(selfPath);
    +    rmdir(projectDir);
    +}
    +
    +
    +static void testBreakpointNotTrapped(const char *name) {
    +    const char *source =
    +        "ON ERROR GOTO h\n"
    +        "PRINT \"a\"\n"
    +        "PRINT \"b\"\n"
    +        "END\n"
    +        "h:\n"
    +        "PRINT \"HANDLER\"\n"
    +        "RESUME NEXT\n";
    +    int32_t    breakLine = 3;
    +    BasParserT parser;
    +
    +    basParserInit(&parser, source, (int32_t)strlen(source));
    +
    +    if (!basParse(&parser)) {
    +        reportFail(name, parser.error);
    +        basParserFree(&parser);
    +        return;
    +    }
    +
    +    BasModuleT *mod = basParserBuildModule(&parser);
    +    basParserFree(&parser);
    +
    +    BasVmT  *vm = basVmCreate();
    +    CaptureT cap;
    +
    +    captureReset(&cap);
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +    basVmSetBreakpoints(vm, &breakLine, 1);
    +
    +    BasVmResultE first = basVmRun(vm);
    +
    +    vm->running = true;
    +
    +    BasVmResultE second = basVmRun(vm);
    +
    +    if (first != BAS_VM_BREAKPOINT || second != BAS_VM_HALTED || strcmp(cap.buf, "a\nb\n") != 0) {
    +        char detail[512];
    +        snprintf(detail, sizeof(detail), "first=%d second=%d out=[%s]", (int)first, (int)second, cap.buf);
    +        reportFail(name, detail);
    +    } else {
    +        reportPass(name);
    +    }
    +
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +}
    +
    +
    +static void testBytecode(const char *name, const char *source, const uint8_t *expected, int32_t expectedLen) {
    +    BasParserT parser;
    +    char       detail[512];
    +
    +    basParserInit(&parser, source, (int32_t)strlen(source));
    +
    +    if (!basParse(&parser)) {
    +        reportFail(name, parser.error);
    +        basParserFree(&parser);
    +        return;
    +    }
    +
    +    if (parser.cg.codeLen != expectedLen || memcmp(parser.cg.code, expected, expectedLen) != 0) {
    +        int32_t n = 0;
    +
    +        n += snprintf(detail + n, sizeof(detail) - n, "len=%d got:", (int)parser.cg.codeLen);
    +
    +        for (int32_t i = 0; i < parser.cg.codeLen && n < (int32_t)sizeof(detail) - 4; i++) {
    +            n += snprintf(detail + n, sizeof(detail) - n, " %02X", parser.cg.code[i]);
    +        }
    +
    +        reportFail(name, detail);
    +    } else {
    +        reportPass(name);
    +    }
    +
    +    basParserFree(&parser);
    +}
    +
    +
    +// TEST_COMPILE_ERROR -- expect compilation to fail; substring of the
    +// error message must match.  Pass an empty needle to accept any error.
    +static void testCompileError(const char *name, const char *source, const char *needle) {
    +    char out[CAPTURE_MAX];
    +    char err[256] = "";
    +
    +    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));
    +
    +    if (rc != -1) {
    +        char detail[256];
    +        snprintf(detail, sizeof(detail), "expected compile error; got rc=%d output=[%s]", (int)rc, out);
    +        reportFail(name, detail);
    +        return;
    +    }
    +
    +    if (needle && needle[0] && strstr(err, needle) == NULL) {
    +        char detail[512];
    +        snprintf(detail, sizeof(detail), "error [%s] did not contain [%s]", err, needle);
    +        reportFail(name, detail);
    +        return;
    +    }
    +
    +    reportPass(name);
    +}
    +
    +
    +// Debug-info side channel: serialize the debugVars table, wipe it on a
    +// second compile of the same source, deserialize, and compare.  Also
    +// drives the truncated-stream rejection paths.
    +static void testDebuggerApi(void) {
    +    const char *source =
    +        "DIM x AS INTEGER\n"
    +        "x = 1\n"
    +        "Bump\n"
    +        "x = x + 1\n"
    +        "x = x + 1\n"
    +        "PRINT x\n"
    +        "END\n"
    +        "SUB Bump\n"
    +        "  PRINT \"in\"\n"
    +        "END SUB\n";
    +    static int32_t breakLines[] = { 2, 9 };
    +    BasModuleT    *mod          = compileSource("debugger-api", source);
    +
    +    if (!mod) {
    +        return;
    +    }
    +
    +    BasVmT  *vm = basVmCreate();
    +    CaptureT cap;
    +
    +    captureReset(&cap);
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +    basVmSetBreakpoints(vm, breakLines, (int32_t)(sizeof(breakLines) / sizeof(breakLines[0])));
    +    primeMainFrame(vm, mod);
    +
    +    // run -> bp line 2; step into -> line 3; run -> bp line 9 (in SUB);
    +    // step out -> line 4; run to cursor 6 -> line 6; run -> halt.
    +    BasVmResultE r  = basVmRun(vm);
    +    bool         ok = r == BAS_VM_BREAKPOINT && vm->currentLine == 2;
    +    char         detail[256];
    +
    +    snprintf(detail, sizeof(detail), "stop1 r=%d line=%d", (int32_t)r, (int32_t)vm->currentLine);
    +    expectTrue("debugger-breakpoint-hit", ok, detail);
    +
    +    basVmStepInto(vm);
    +    vm->running = true;
    +    r = basVmRun(vm);
    +    snprintf(detail, sizeof(detail), "stop2 r=%d line=%d", (int32_t)r, (int32_t)vm->currentLine);
    +    expectTrue("debugger-step-into", r == BAS_VM_BREAKPOINT && vm->currentLine == 3, detail);
    +
    +    vm->running = true;
    +    r = basVmRun(vm);
    +    snprintf(detail, sizeof(detail), "stop3 r=%d line=%d", (int32_t)r, (int32_t)vm->currentLine);
    +    expectTrue("debugger-breakpoint-in-sub", r == BAS_VM_BREAKPOINT && vm->currentLine == 9, detail);
    +
    +    basVmStepOut(vm);
    +    vm->running = true;
    +    r = basVmRun(vm);
    +    snprintf(detail, sizeof(detail), "stop4 r=%d line=%d", (int32_t)r, (int32_t)vm->currentLine);
    +    expectTrue("debugger-step-out", r == BAS_VM_BREAKPOINT && vm->currentLine == 4, detail);
    +
    +    basVmRunToCursor(vm, 6);
    +    vm->running = true;
    +    r = basVmRun(vm);
    +    snprintf(detail, sizeof(detail), "stop5 r=%d line=%d", (int32_t)r, (int32_t)vm->currentLine);
    +    expectTrue("debugger-run-to-cursor", r == BAS_VM_BREAKPOINT && vm->currentLine == 6, detail);
    +
    +    vm->running = true;
    +    r = basVmRun(vm);
    +    snprintf(detail, sizeof(detail), "end r=%d out=[%s]", (int32_t)r, cap.buf);
    +    expectTrue("debugger-resume-to-end", r == BAS_VM_HALTED && strcmp(cap.buf, "in\n3 \n") == 0, detail);
    +
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +
    +    // Step over: the SUB call on line 3 runs to completion and execution
    +    // stops on line 4 without stopping inside Bump.
    +    mod = compileSource("debugger-step-over", source);
    +
    +    if (!mod) {
    +        return;
    +    }
    +
    +    static int32_t stepOverBp[] = { 3 };
    +
    +    vm = basVmCreate();
    +    captureReset(&cap);
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +    basVmSetBreakpoints(vm, stepOverBp, 1);
    +    primeMainFrame(vm, mod);
    +    r = basVmRun(vm);
    +    expectTrue("debugger-step-over-arm", r == BAS_VM_BREAKPOINT && vm->currentLine == 3, "did not stop on the call line");
    +
    +    basVmStepOver(vm);
    +    vm->running = true;
    +    r = basVmRun(vm);
    +    snprintf(detail, sizeof(detail), "r=%d line=%d out=[%s]", (int32_t)r, (int32_t)vm->currentLine, cap.buf);
    +    expectTrue("debugger-step-over", r == BAS_VM_BREAKPOINT && vm->currentLine == 4 && strcmp(cap.buf, "in\n") == 0, detail);
    +
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +
    +    // runSubLoop: a breakpoint inside a host-called SUB pauses in place,
    +    // notifies breakpointFn, and resumes through the doEvents callback.
    +    mod = compileSource("debugger-callsub", source);
    +
    +    if (!mod) {
    +        return;
    +    }
    +
    +    const BasProcEntryT *proc = basModuleFindProc(mod, "Bump");
    +
    +    if (!proc) {
    +        reportFail("debugger-callsub", "Bump not in proc table");
    +        basModuleFree(mod);
    +        return;
    +    }
    +
    +    static int32_t subBp[] = { 9 };
    +
    +    vm = basVmCreate();
    +    captureReset(&cap);
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +    basVmSetBreakpoints(vm, subBp, 1);
    +    basVmSetDoEventsCallback(vm, doEventsResumeStub, vm);
    +    vm->breakpointFn  = breakpointRecordStub;
    +    vm->breakpointCtx = NULL;
    +    sBpCount          = 0;
    +
    +    bool called = basVmCallSub(vm, proc->codeAddr);
    +
    +    snprintf(detail, sizeof(detail), "called=%d bpCount=%d out=[%s]", (int32_t)called, (int32_t)sBpCount, cap.buf);
    +    expectTrue("debugger-callsub-resume", called && sBpCount == 2 && sBpLines[0] == 9 && sBpLines[1] == -1 && strcmp(cap.buf, "in\n") == 0, detail);
    +
    +    // While paused at a breakpoint the VM rejects re-entrant sub calls.
    +    vm->debugPaused = true;
    +    expectTrue("debugger-callsub-paused-rejected", !basVmCallSub(vm, proc->codeAddr), "call while paused must be refused");
    +    vm->debugPaused = false;
    +
    +    // A doEvents callback reporting shutdown aborts the paused sub.
    +    basVmSetDoEventsCallback(vm, doEventsAbortStub, NULL);
    +    expectTrue("debugger-callsub-abort", !basVmCallSub(vm, proc->codeAddr), "shutdown during pause must abort");
    +
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +
    +    // An uncaught error inside a nested SUB call unwinds every frame the
    +    // host call pushed and reports failure.
    +    const char *nestedErr =
    +        "SUB Outer\n"
    +        "  Inner\n"
    +        "END SUB\n"
    +        "SUB Inner\n"
    +        "  DIM s AS STRING\n"
    +        "  s = \"live\"\n"
    +        "  ERROR 5\n"
    +        "END SUB\n";
    +    char nestedOut[CAPTURE_MAX];
    +    char nestedErrMsg[256] = "";
    +
    +    expectTrue("callsub-nested-error", runSubAndCapture(nestedErr, "Outer", nestedOut, sizeof(nestedOut), nestedErrMsg, sizeof(nestedErrMsg)) == 1, nestedErrMsg);
    +
    +    // The periodic yield inside runSubLoop: a countdown callback lets a
    +    // long-running SUB yield a few times, then reports shutdown, which
    +    // stops the VM mid-sub (basVmCallSub still returns true: no error).
    +    const char *loopSource =
    +        "SUB Spin\n"
    +        "  DIM i AS LONG\n"
    +        "  DIM n AS LONG\n"
    +        "  FOR i = 1 TO 100000\n"
    +        "    n = n + 1\n"
    +        "  NEXT i\n"
    +        "END SUB\n";
    +
    +    mod = compileSource("debugger-yield", loopSource);
    +
    +    if (!mod) {
    +        return;
    +    }
    +
    +    proc = basModuleFindProc(mod, "Spin");
    +    vm   = basVmCreate();
    +    basVmLoadModule(vm, mod);
    +
    +    int32_t countdown = 2;
    +
    +    basVmSetDoEventsCallback(vm, doEventsCountdownStub, &countdown);
    +    called = basVmCallSub(vm, proc->codeAddr);
    +    snprintf(detail, sizeof(detail), "called=%d countdown=%d", (int32_t)called, (int32_t)countdown);
    +    expectTrue("debugger-callsub-yield-abort", called && countdown == 0, detail);
    +
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +}
    +
    +
    +static void testDebugInfoRoundTrip(const char *name) {
    +    const char *source =
    +        "DIM g AS INTEGER\n"
    +        "DIM s AS STRING\n"
    +        "SUB Poke(v AS INTEGER)\n"
    +        "  DIM loc AS LONG\n"
    +        "  loc = v\n"
    +        "END SUB\n"
    +        "g = 1\n";
    +    BasModuleT *mod = compileSource(name, source);
    +
    +    if (!mod) {
    +        return;
    +    }
    +
    +    if (mod->debugVarCount < 3) {
    +        reportFail(name, "compiler emitted no debug vars");
    +        basModuleFree(mod);
    +        return;
    +    }
    +
    +    int32_t  len  = 0;
    +    uint8_t *data = basDebugSerialize(mod, &len);
    +
    +    if (!data || len <= 0) {
    +        reportFail(name, "basDebugSerialize failed");
    +        basModuleFree(mod);
    +        return;
    +    }
    +
    +    BasModuleT *back = compileSource(name, source);
    +
    +    if (!back) {
    +        free(data);
    +        basModuleFree(mod);
    +        return;
    +    }
    +
    +    free(back->debugVars);
    +    back->debugVars     = NULL;
    +    back->debugVarCount = 0;
    +    basDebugDeserialize(back, data, len);
    +
    +    bool ok = back->debugVarCount == mod->debugVarCount;
    +
    +    for (int32_t i = 0; ok && i < mod->debugVarCount; i++) {
    +        ok = strcmp(back->debugVars[i].name, mod->debugVars[i].name) == 0 && back->debugVars[i].scope == mod->debugVars[i].scope && back->debugVars[i].index == mod->debugVars[i].index;
    +    }
    +
    +    expectTrue(name, ok, "debug vars did not round trip");
    +
    +    // A truncated stream must drop to no-debug-info, not load garbage.
    +    free(back->debugVars);
    +    back->debugVars     = NULL;
    +    back->debugVarCount = 0;
    +    basDebugDeserialize(back, data, len / 2);
    +    expectTrue("debug-info-truncated-dropped", back->debugVarCount == 0, "truncated debug info was not rejected");
    +
    +    // Streams shorter than the count header are ignored outright.
    +    basDebugDeserialize(back, data, BAS_DBG_TINY_LEN);
    +    expectTrue("debug-info-tiny-ignored", back->debugVarCount == 0, "tiny debug info was not ignored");
    +
    +    // A cut inside the last entry's payload latches the read error after
    +    // the table was allocated: the half-read table must be dropped.
    +    basDebugDeserialize(back, data, len - 1);
    +    expectTrue("debug-info-payload-cut", back->debugVarCount == 0, "payload-cut debug info was not dropped");
    +
    +    free(data);
    +    basModuleFree(back);
    +    basModuleFree(mod);
    +}
    +
    +
    +// Breakpoints, step into/over/out, run to cursor, and the runSubLoop
    +// pause/resume/abort protocol used by the IDE debugger.
     
     
     static void testDispatchEq(const char *name, const TestDispatchT *d, const char *expected) {
    @@ -955,15 +1517,1704 @@ static void testDispatchEq(const char *name, const TestDispatchT *d, const char
     }
     
     
    -// Helper macros to keep tests compact.  `overrides` and `fires` are
    -// comma lists terminated with NULL/NULL.
    -#define DISPATCH_OVR(...) { __VA_ARGS__, NULL, NULL }
    -#define DISPATCH_FIRE(...) { __VA_ARGS__, NULL }
    +// TEST_EQ -- compile, run, expect captured output to match
    +static void testEq(const char *name, const char *source, const char *expected) {
    +    char out[CAPTURE_MAX];
    +    char err[256] = "";
    +
    +    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));
    +
    +    if (rc != 0) {
    +        char detail[512];
    +        snprintf(detail, sizeof(detail), "rc=%d err=%s", (int)rc, err);
    +        reportFail(name, detail);
    +        return;
    +    }
    +
    +    if (strcmp(out, expected) != 0) {
    +        reportFailDiff(name, expected, out);
    +        return;
    +    }
    +
    +    reportPass(name);
    +}
     
     
    -// ============================================================
    -// Test cases
    -// ============================================================
    +// DECLARE LIBRARY calls through the host resolve/call callbacks: the
    +// happy path, the per-pair resolve cache, and the unresolved error.
    +static void testExternCalls(void) {
    +    const char *source =
    +        "DECLARE LIBRARY \"test\"\n"
    +        "    DECLARE FUNCTION Add2(BYVAL a AS INTEGER, BYVAL b AS INTEGER) AS INTEGER\n"
    +        "END DECLARE\n"
    +        "PRINT Add2(2, 3)\n"
    +        "PRINT Add2(10, 20)\n";
    +    BasModuleT *mod = compileSource("extern-calls", source);
    +
    +    if (!mod) {
    +        return;
    +    }
    +
    +    BasExternCallbacksT ext;
    +    BasVmT             *vm = basVmCreate();
    +    CaptureT            cap;
    +    char                detail[256];
    +
    +    ext.resolveExtern = externResolveStub;
    +    ext.callExtern    = externCallStub;
    +    ext.ctx           = NULL;
    +    sExternResolves   = 0;
    +
    +    captureReset(&cap);
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +    basVmSetExternCallbacks(vm, &ext);
    +    primeMainFrame(vm, mod);
    +
    +    BasVmResultE r = basVmRun(vm);
    +
    +    snprintf(detail, sizeof(detail), "r=%d out=[%s] resolves=%d", (int32_t)r, cap.buf, (int32_t)sExternResolves);
    +    expectTrue("extern-call-and-cache", r == BAS_VM_HALTED && strcmp(cap.buf, "5 \n30 \n") == 0 && sExternResolves == 1, detail);
    +
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +
    +    // The resolver refuses "Missing": the VM reports the lookup failure.
    +    const char *missingSource =
    +        "DECLARE LIBRARY \"test\"\n"
    +        "    DECLARE SUB Missing()\n"
    +        "END DECLARE\n"
    +        "Missing\n";
    +
    +    mod = compileSource("extern-missing", missingSource);
    +
    +    if (!mod) {
    +        return;
    +    }
    +
    +    vm = basVmCreate();
    +    captureReset(&cap);
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +    basVmSetExternCallbacks(vm, &ext);
    +    primeMainFrame(vm, mod);
    +    r = basVmRun(vm);
    +    snprintf(detail, sizeof(detail), "r=%d err=[%s]", (int32_t)r, basVmGetError(vm));
    +    expectTrue("extern-missing-error", r != BAS_VM_HALTED && strstr(basVmGetError(vm), "External function not found") != NULL, detail);
    +    basVmDestroy(vm);
    +
    +    // Unresolved externs with pending arguments drain them from the
    +    // stack before raising.
    +    const char *missingArgs =
    +        "DECLARE LIBRARY \"test\"\n"
    +        "    DECLARE SUB Missing(BYVAL a AS INTEGER, BYVAL b AS INTEGER)\n"
    +        "END DECLARE\n"
    +        "ON ERROR GOTO H\n"
    +        "Missing 1, 2\n"
    +        "PRINT \"walked\"\nEND\nH:\nPRINT ERR\nRESUME NEXT\n";
    +    BasModuleT *argMod = compileSource("extern-missing-args", missingArgs);
    +
    +    if (argMod) {
    +        BasVmT *argVm = basVmCreate();
    +
    +        captureReset(&cap);
    +        basVmLoadModule(argVm, argMod);
    +        basVmSetPrintCallback(argVm, capturePrint, &cap);
    +        basVmSetExternCallbacks(argVm, &ext);
    +        primeMainFrame(argVm, argMod);
    +        basVmRun(argVm);
    +        expectTrue("extern-missing-args-drain", strcmp(cap.buf, "453 \nwalked\n") == 0, cap.buf);
    +        basVmDestroy(argVm);
    +        basModuleFree(argMod);
    +    }
    +
    +    // With no callbacks installed at all the same lookup fails cleanly.
    +    vm = basVmCreate();
    +    captureReset(&cap);
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +    primeMainFrame(vm, mod);
    +    r = basVmRun(vm);
    +    expectTrue("extern-no-callbacks-error", r != BAS_VM_HALTED && strstr(basVmGetError(vm), "External function not found") != NULL, basVmGetError(vm));
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +}
    +
    +
    +static void testHandAssembled(void) {
    +    HandCodeT   h;
    +    BasStringT *consts[3];
    +
    +    // PUSH_STR 0; PRINT; PRINT_NL; HALT
    +    memset(&h, 0, sizeof(h));
    +    consts[0] = basStringNew("Hello, World!", 13);
    +    handEmit8(&h, OP_PUSH_STR);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PRINT_NL);
    +    handEmit8(&h, OP_HALT);
    +    testHandRun("hand-print-string", &h, consts, 1, 0, "Hello, World!\n");
    +    basStringUnref(consts[0]);
    +
    +    // PUSH 3; PUSH 4; MUL; PUSH 2; ADD; PRINT; PRINT_NL; HALT
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 3);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 4);
    +    handEmit8(&h, OP_MUL_INT);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 2);
    +    handEmit8(&h, OP_ADD_INT);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PRINT_NL);
    +    handEmit8(&h, OP_HALT);
    +    testHandRun("hand-int-arith", &h, NULL, 0, 0, "14 \n");
    +
    +    // An array stored into one of its own elements (nothing the compiler
    +    // emits, but any mutated module can) must be rejected rather than
    +    // building a reference cycle the VM can never free: DIM a(0 TO 3);
    +    // a(1) = a; also a global that already holds the array.
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 0);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 3);
    +    handEmit8(&h, OP_DIM_ARRAY);
    +    handEmit8(&h, 1);
    +    handEmit8(&h, BAS_TYPE_INTEGER);
    +    handEmit8(&h, OP_STORE_GLOBAL);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_LOAD_GLOBAL);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_LOAD_GLOBAL);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_STORE_ARRAY);
    +    handEmit8(&h, 1);
    +    handEmit8(&h, OP_HALT);
    +    testHandRunError("hand-array-self-store-rejected", &h, NULL, 0, 1, "Array reference cannot be stored");
    +
    +    // "Hello" & " " & "BASIC"
    +    memset(&h, 0, sizeof(h));
    +    consts[0] = basStringNew("Hello", 5);
    +    consts[1] = basStringNew(" ", 1);
    +    consts[2] = basStringNew("BASIC", 5);
    +    handEmit8(&h, OP_PUSH_STR);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_PUSH_STR);
    +    handEmitU16(&h, 1);
    +    handEmit8(&h, OP_STR_CONCAT);
    +    handEmit8(&h, OP_PUSH_STR);
    +    handEmitU16(&h, 2);
    +    handEmit8(&h, OP_STR_CONCAT);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PRINT_NL);
    +    handEmit8(&h, OP_HALT);
    +    testHandRun("hand-str-concat", &h, consts, 3, 0, "Hello BASIC\n");
    +    basStringUnref(consts[0]);
    +    basStringUnref(consts[1]);
    +    basStringUnref(consts[2]);
    +
    +    // FOR i = 1 TO 5: PRINT i: NEXT  (local 0 is the loop variable)
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_STORE_LOCAL);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 5);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_FOR_INIT);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, SCOPE_LOCAL);
    +
    +    int32_t skipPatch = h.len;
    +
    +    handEmit16(&h, 0);
    +
    +    int32_t loopBody = h.len;
    +
    +    handEmit8(&h, OP_LOAD_LOCAL);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_FOR_NEXT);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, SCOPE_LOCAL);
    +    handEmit16(&h, (int16_t)(loopBody - (h.len + (int32_t)sizeof(int16_t))));
    +
    +    int16_t skipOffset = (int16_t)(h.len - loopBody);
    +
    +    memcpy(&h.code[skipPatch], &skipOffset, sizeof(skipOffset));
    +    handEmit8(&h, OP_PRINT_NL);
    +    handEmit8(&h, OP_HALT);
    +    testHandRun("hand-for-loop", &h, NULL, 0, 1, "1 2 3 4 5 \n");
    +}
    +
    +
    +// Run a hand-assembled module and compare the captured PRINT output.
    +// Hand-assembled sequences for VM guard paths the compiler never emits:
    +// unknown opcodes, truncated operands, stack overflow/underflow, and the
    +// logic/conversion opcodes with direct stack values.
    +static void testHandBattery(void) {
    +    HandCodeT h;
    +
    +    // Unknown opcode byte.
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, HAND_BAD_OPCODE);
    +    testHandRunError("hand-unknown-opcode", &h, NULL, 0, 0, "");
    +
    +    // Operand truncated by the end of code[].
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit8(&h, 0x01);
    +    testHandRunError("hand-truncated-operand", &h, NULL, 0, 0, "");
    +
    +    // Eval-stack overflow: push in an infinite loop.
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_JMP);
    +    handEmit16(&h, (int16_t)-HAND_PUSH_JMP_LEN);
    +    testHandRunError("hand-stack-overflow", &h, NULL, 0, 0, "");
    +
    +    // Eval-stack underflow: POP with nothing pushed.
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_POP);
    +    handEmit8(&h, OP_HALT);
    +    testHandRunError("hand-stack-underflow", &h, NULL, 0, 0, "");
    +
    +    // DUP + logic ops on integers.
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 6);
    +    handEmit8(&h, OP_DUP);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 3);
    +    handEmit8(&h, OP_AND);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 3);
    +    handEmit8(&h, OP_OR);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 6);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 3);
    +    handEmit8(&h, OP_XOR);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 6);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 3);
    +    handEmit8(&h, OP_EQV);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 6);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 3);
    +    handEmit8(&h, OP_IMP);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 6);
    +    handEmit8(&h, OP_NOT);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PRINT_NL);
    +    handEmit8(&h, OP_HALT);
    +    testHandRun("hand-logic-ops", &h, NULL, 0, 0, "2 7 5 -6 -5 -7 \n");
    +
    +    // Conversions: int -> float -> int -> string, VAL, CDBL, CLNG, CBOOL.
    +    BasStringT *consts[1];
    +
    +    memset(&h, 0, sizeof(h));
    +    consts[0] = basStringNew("41", 2);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 7);
    +    handEmit8(&h, OP_CONV_INT_FLT);
    +    handEmit8(&h, OP_CONV_FLT_INT);
    +    handEmit8(&h, OP_CONV_INT_STR);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PUSH_STR);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_CONV_STR_INT);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PUSH_STR);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_CONV_STR_FLT);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 9);
    +    handEmit8(&h, OP_CONV_INT_LONG);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 5);
    +    handEmit8(&h, OP_CONV_BOOL);
    +    handEmit8(&h, OP_PRINT);
    +    handEmit8(&h, OP_PRINT_NL);
    +    handEmit8(&h, OP_HALT);
    +    testHandRun("hand-conversions", &h, consts, 1, 0, "741 41 9 True \n");
    +    basStringUnref(consts[0]);
    +
    +    // GOSUB_RET jumps to the address pushed on the eval stack; OP_STMT
    +    // and OP_NOP are executable no-ops.
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT32);
    +
    +    int32_t targetAddr = HAND_GOSUB_TARGET;
    +
    +    memcpy(&h.code[h.len], &targetAddr, sizeof(targetAddr));
    +    h.len += (int32_t)sizeof(targetAddr);
    +    handEmit8(&h, OP_GOSUB_RET);
    +    handEmit8(&h, OP_STMT);
    +    handEmit8(&h, OP_NOP);
    +    handEmit8(&h, OP_HALT);
    +    testHandRun("hand-gosub-ret", &h, NULL, 0, 0, "");
    +
    +    // FOR_POP discards the innermost FOR frame (EXIT FOR).
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_STORE_LOCAL);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 3);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_FOR_INIT);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, SCOPE_LOCAL);
    +    handEmit16(&h, HAND_FOR_SKIP);
    +    handEmit8(&h, OP_FOR_POP);
    +    handEmit8(&h, OP_HALT);
    +    testHandRun("hand-for-pop", &h, NULL, 0, 1, "");
    +}
    +
    +
    +static void testHandRun(const char *name, const HandCodeT *h, BasStringT **consts, int32_t constCount, int32_t localCount, const char *expected) {
    +    BasModuleT module;
    +
    +    memset(&module, 0, sizeof(module));
    +    module.code       = (uint8_t *)h->code;
    +    module.codeLen    = h->len;
    +    module.constants  = consts;
    +    module.constCount = constCount;
    +    module.entryPoint = 0;
    +
    +    BasVmT  *vm = basVmCreate();
    +    CaptureT cap;
    +
    +    captureReset(&cap);
    +    basVmLoadModule(vm, &module);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +
    +    vm->callStack[0].localCount = localCount;
    +    vm->callDepth               = 1;
    +
    +    BasVmResultE result = basVmRun(vm);
    +
    +    if (result != BAS_VM_HALTED) {
    +        char detail[256];
    +        snprintf(detail, sizeof(detail), "result=%d (%s) out=[%s]", (int)result, basVmGetError(vm), cap.buf);
    +        reportFail(name, detail);
    +    } else if (strcmp(cap.buf, expected) != 0) {
    +        reportFailDiff(name, expected, cap.buf);
    +    } else {
    +        reportPass(name);
    +    }
    +
    +    basVmDestroy(vm);
    +}
    +
    +
    +// Run a hand-assembled module that must stop with a runtime error whose
    +// text contains needle.
    +static void testHandRunError(const char *name, const HandCodeT *h, BasStringT **consts, int32_t constCount, int32_t localCount, const char *needle) {
    +    BasModuleT module;
    +
    +    memset(&module, 0, sizeof(module));
    +    module.code       = (uint8_t *)h->code;
    +    module.codeLen    = h->len;
    +    module.constants  = consts;
    +    module.constCount = constCount;
    +    module.entryPoint = 0;
    +
    +    BasVmT  *vm = basVmCreate();
    +    CaptureT cap;
    +
    +    captureReset(&cap);
    +    basVmLoadModule(vm, &module);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +
    +    vm->callStack[0].localCount = localCount;
    +    vm->callDepth               = 1;
    +
    +    BasVmResultE result = basVmRun(vm);
    +
    +    if (result == BAS_VM_HALTED || result == BAS_VM_OK) {
    +        reportFail(name, "expected a runtime error");
    +    } else if (!strstr(basVmGetError(vm), needle)) {
    +        char detail[256];
    +        snprintf(detail, sizeof(detail), "error=[%s] expected [%s]", basVmGetError(vm), needle);
    +        reportFail(name, detail);
    +    } else {
    +        reportPass(name);
    +    }
    +
    +    basVmDestroy(vm);
    +}
    +
    +
    +// INPUT through the host callback: prompt text, an accepted line, and a
    +// cancelled read (empty result).
    +static void testInputCallback(const char *name) {
    +    const char *source =
    +        "DIM a AS INTEGER\n"
    +        "INPUT \"Age\"; a\n"
    +        "PRINT a\n"
    +        "DIM s AS STRING\n"
    +        "INPUT s\n"
    +        "PRINT \"[\" & s & \"]\"\n";
    +    BasModuleT *mod = compileSource(name, source);
    +
    +    if (!mod) {
    +        return;
    +    }
    +
    +    BasVmT  *vm = basVmCreate();
    +    CaptureT cap;
    +    char     detail[256];
    +
    +    sInputLog[0] = '\0';
    +    sInputCalls  = 0;
    +    captureReset(&cap);
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, capturePrint, &cap);
    +    basVmSetInputCallback(vm, inputStub, NULL);
    +    primeMainFrame(vm, mod);
    +
    +    BasVmResultE r = basVmRun(vm);
    +
    +    snprintf(detail, sizeof(detail), "r=%d out=[%s] prompts=[%s]", (int32_t)r, cap.buf, sInputLog);
    +    expectTrue(name, r == BAS_VM_HALTED && strcmp(cap.buf, "42 \n[]\n") == 0 && strcmp(sInputLog, "Age? |? |") == 0, detail);
    +
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +}
    +
    +
    +static void testLexerTokens(const char *name, const char *source, const ExpectedTokenT *expected, int32_t expectedCount) {
    +    BasLexerT lex;
    +    char      detail[256];
    +
    +    basLexerInit(&lex, source, (int32_t)strlen(source));
    +
    +    for (int32_t i = 0; i < expectedCount; i++) {
    +        if (lex.token.type != expected[i].type || strcmp(lex.token.text, expected[i].text) != 0) {
    +            snprintf(detail, sizeof(detail), "token %d: expected %s [%s], got %s [%s]", (int)i, basTokenName(expected[i].type), expected[i].text, basTokenName(lex.token.type), lex.token.text);
    +            reportFail(name, detail);
    +            return;
    +        }
    +
    +        if (lex.token.type == TOK_EOF) {
    +            break;
    +        }
    +
    +        basLexerNext(&lex);
    +    }
    +
    +    reportPass(name);
    +}
    +
    +
    +// Full release-build payload path: compile, strip, obfuscate against a
    +// .frm, compact, serialize, deserialize, run.  Checks that a SetEvent
    +// handler name survives obfuscation, that a control named after a
    +// property key ("Text") is left alone in both the .frm and the constant
    +// pool, and that the round-tripped module still runs.
    +// basVmLoadModule refuses a module that wants more global slots than the
    +// VM has, and skips out-of-range globalInit entries on a valid one.
    +static void testLoadModuleRefusal(const char *name) {
    +    BasModuleT module;
    +    uint8_t    haltCode[1] = { OP_HALT };
    +
    +    memset(&module, 0, sizeof(module));
    +    module.code        = haltCode;
    +    module.codeLen     = 1;
    +    module.globalCount = BAS_VM_MAX_GLOBALS + 1;
    +
    +    BasVmT *vm = basVmCreate();
    +
    +    basVmLoadModule(vm, &module);
    +
    +    BasVmResultE r = basVmRun(vm);
    +    char         detail[256];
    +
    +    snprintf(detail, sizeof(detail), "module=%p r=%d err=[%s]", (void *)vm->module, (int32_t)r, basVmGetError(vm));
    +    expectTrue(name, vm->module == NULL && r == BAS_VM_ERROR && strstr(basVmGetError(vm), "limit is") != NULL, detail);
    +    basVmDestroy(vm);
    +
    +    // Out-of-range globalInit slots are skipped; in-range STRING inits
    +    // seed the slot with an empty string.
    +    BasGlobalInitT inits[3];
    +
    +    inits[0].index    = -1;
    +    inits[0].dataType = BAS_TYPE_STRING;
    +    inits[1].index    = BAS_VM_MAX_GLOBALS + 10;
    +    inits[1].dataType = BAS_TYPE_STRING;
    +    inits[2].index    = 1;
    +    inits[2].dataType = BAS_TYPE_STRING;
    +
    +    memset(&module, 0, sizeof(module));
    +    module.code            = haltCode;
    +    module.codeLen         = 1;
    +    module.globalCount     = 2;
    +    module.globalInits     = inits;
    +    module.globalInitCount = 3;
    +
    +    vm = basVmCreate();
    +    basVmLoadModule(vm, &module);
    +    r = basVmRun(vm);
    +    snprintf(detail, sizeof(detail), "r=%d slotType=%d", (int32_t)r, (int32_t)vm->globals[1].type);
    +    expectTrue("load-module-global-inits", r == BAS_VM_HALTED && vm->globals[1].type == BAS_TYPE_STRING && vm->globals[1].strVal != NULL, detail);
    +    basVmDestroy(vm);
    +}
    +
    +
    +// One hand-assembled sequence for the guard battery.
    +typedef struct {
    +    const char *name;
    +    uint8_t     code[GUARD_CODE_MAX];
    +    int32_t     len;
    +    bool        prefill;    // run with a full eval stack (push guards)
    +} GuardCaseT;
    +
    +
    +// VM guard paths the compiler never emits: every pop on an empty stack,
    +// every push on a full one, and the bad-index / bad-type operand guards.
    +static void testOpcodeGuards(void) {
    +    // Underflow battery: each op runs with an empty eval stack; overflow
    +    // battery: each op runs with the stack already full.
    +    static const GuardCaseT guards[] = {
    +        { "pop",             { OP_POP }, 1, false },
    +        { "print",           { OP_PRINT }, 1, false },
    +        { "jmp-true",        { OP_JMP_TRUE, 0, 0 }, 3, false },
    +        { "store-local",     { OP_STORE_LOCAL, 0, 0 }, 3, false },
    +        { "store-global",    { OP_STORE_GLOBAL, 0, 0 }, 3, false },
    +        { "gosub-ret",       { OP_GOSUB_RET }, 1, false },
    +        { "add",             { OP_PUSH_TRUE, OP_ADD_INT }, 2, false },
    +        { "cmp",             { OP_PUSH_TRUE, OP_CMP_EQ }, 2, false },
    +        { "concat",          { OP_PUSH_TRUE, OP_STR_CONCAT }, 2, false },
    +        { "instr3",          { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_STR_INSTR3 }, 3, false },
    +        { "mid-asgn",        { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_PUSH_TRUE, OP_STR_MID_ASGN }, 4, false },
    +        { "strfix",          { OP_STR_FIXLEN, 4, 0 }, 3, false },
    +        { "string",          { OP_PUSH_TRUE, OP_STR_STRING }, 2, false },
    +        { "rgb",             { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_RGB }, 3, false },
    +        { "get-red",         { OP_GET_RED }, 1, false },
    +        { "raise",           { OP_RAISE_ERR }, 1, false },
    +        { "sleep",           { OP_SLEEP }, 1, false },
    +        { "environ",         { OP_ENVIRON }, 1, false },
    +        { "shell",           { OP_SHELL }, 1, false },
    +        { "format",          { OP_PUSH_TRUE, OP_FORMAT }, 2, false },
    +        { "print-using",     { OP_PUSH_TRUE, OP_PRINT_USING, 0 }, 3, false },
    +        { "print-spc",       { OP_PRINT_SPC_N }, 1, false },
    +        { "print-tab",       { OP_PRINT_TAB_N }, 1, false },
    +        { "dim-array",       { OP_PUSH_TRUE, OP_DIM_ARRAY, 1, BAS_TYPE_INTEGER }, 4, false },
    +        { "load-array",      { OP_PUSH_TRUE, OP_LOAD_ARRAY, 1 }, 3, false },
    +        { "store-array",     { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_STORE_ARRAY, 1 }, 4, false },
    +        { "arr-addr",        { OP_PUSH_TRUE, OP_PUSH_ARR_ADDR, 1 }, 3, false },
    +        { "store-arr-field", { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_STORE_ARRAY_FIELD, 1, 0, 0 }, 6, false },
    +        { "load-field",      { OP_LOAD_FIELD, 0, 0 }, 3, false },
    +        { "store-field",     { OP_PUSH_TRUE, OP_STORE_FIELD, 0, 0 }, 4, false },
    +        { "redim",           { OP_PUSH_TRUE, OP_REDIM, 1, 0, BAS_TYPE_INTEGER }, 5, false },
    +        { "erase",           { OP_ERASE, 0 }, 2, false },
    +        { "lbound",          { OP_LBOUND, 1 }, 2, false },
    +        { "ini-write",       { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_INI_WRITE }, 3, false },
    +        { "ini-read",        { OP_PUSH_TRUE, OP_INI_READ }, 2, false },
    +        { "file-open",       { OP_FILE_OPEN, 0 }, 2, false },
    +        { "file-close",      { OP_FILE_CLOSE }, 1, false },
    +        { "file-print",      { OP_FILE_PRINT }, 1, false },
    +        { "file-eof",        { OP_FILE_EOF }, 1, false },
    +        { "file-write",      { OP_FILE_WRITE }, 1, false },
    +        { "file-get",        { OP_FILE_GET }, 1, false },
    +        { "file-put",        { OP_FILE_PUT }, 1, false },
    +        { "file-seek",       { OP_FILE_SEEK }, 1, false },
    +        { "file-input-n",    { OP_FILE_INPUT_N }, 1, false },
    +        { "fs-kill",         { OP_FS_KILL }, 1, false },
    +        { "fs-name",         { OP_PUSH_TRUE, OP_FS_NAME }, 2, false },
    +        { "fs-filecopy",     { OP_PUSH_TRUE, OP_FS_FILECOPY }, 2, false },
    +        { "fs-mkdir",        { OP_FS_MKDIR }, 1, false },
    +        { "fs-setattr",      { OP_PUSH_TRUE, OP_FS_SETATTR }, 2, false },
    +        { "msgbox",          { OP_PUSH_TRUE, OP_MSGBOX }, 2, false },
    +        { "inputbox",        { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_INPUTBOX }, 3, false },
    +        { "show-form",       { OP_SHOW_FORM, 0 }, 2, false },
    +        { "load-form",       { OP_LOAD_FORM }, 1, false },
    +        { "create-form",     { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_CREATE_FORM }, 3, false },
    +        { "create-ctrl",     { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_CREATE_CTRL }, 3, false },
    +        { "create-ctrl-ex",  { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_PUSH_TRUE, OP_CREATE_CTRL_EX }, 4, false },
    +        { "find-ctrl",       { OP_PUSH_TRUE, OP_FIND_CTRL }, 2, false },
    +        { "find-ctrl-idx",   { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_FIND_CTRL_IDX }, 3, false },
    +        { "set-event",       { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_SET_EVENT }, 3, false },
    +        { "remove-ctrl",     { OP_PUSH_TRUE, OP_REMOVE_CTRL }, 2, false },
    +        { "call-method",     { OP_PUSH_TRUE, OP_CALL_METHOD, 0 }, 3, false },
    +        { "load-prop",       { OP_PUSH_TRUE, OP_LOAD_PROP }, 2, false },
    +        { "store-prop",      { OP_PUSH_TRUE, OP_PUSH_TRUE, OP_STORE_PROP }, 3, false },
    +        { "push-int16-full", { OP_PUSH_INT16, 1, 0 }, 3, true },
    +        { "push-str-full",   { OP_PUSH_STR, 0, 0 }, 3, true },
    +        { "dup-full",        { OP_DUP }, 1, true },
    +        { "err-num-full",    { OP_ERR_NUM }, 1, true },
    +        { "erl-full",        { OP_ERL }, 1, true },
    +        { "curdir-full",     { OP_FS_CURDIR }, 1, true },
    +        { "freefile-full",   { OP_FILE_FREEFILE }, 1, true },
    +        { "timer-full",      { OP_MATH_TIMER }, 1, true },
    +        { "date-full",       { OP_DATE_STR }, 1, true },
    +        { "time-full",       { OP_TIME_STR }, 1, true },
    +        { "app-path-full",   { OP_APP_PATH }, 1, true },
    +        { "file-input",      { OP_FILE_INPUT }, 1, false },
    +        { "file-line-input", { OP_FILE_LINE_INPUT }, 1, false },
    +        { "file-lof",        { OP_FILE_LOF }, 1, false },
    +        { "file-loc",        { OP_FILE_LOC }, 1, false },
    +        { "file-seek-get",   { OP_FILE_SEEK_GET }, 1, false },
    +        { "file-write-sep",  { OP_FILE_WRITE_SEP }, 1, false },
    +        { "file-write-nl",   { OP_FILE_WRITE_NL }, 1, false },
    +        { "file-put-udt",    { OP_FILE_PUT_UDT }, 1, false },
    +        { "conv-flt-int",    { OP_CONV_FLT_INT }, 1, false },
    +        { "conv-str-int",    { OP_CONV_STR_INT }, 1, false },
    +        { "conv-int-str",    { OP_CONV_INT_STR }, 1, false },
    +        { "str-instr",       { OP_PUSH_TRUE, OP_STR_INSTR }, 2, false },
    +        { "str-left",        { OP_PUSH_TRUE, OP_STR_LEFT }, 2, false },
    +        { "str-chr",         { OP_STR_CHR }, 1, false },
    +        { "str-asc",         { OP_STR_ASC }, 1, false },
    +        { "str-space",       { OP_STR_SPACE }, 1, false },
    +        { "math-abs",        { OP_MATH_ABS }, 1, false },
    +        { "get-green",       { OP_GET_GREEN }, 1, false },
    +        { "get-blue",        { OP_GET_BLUE }, 1, false },
    +        { "app-config-full", { OP_APP_CONFIG }, 1, true },
    +        { "app-data-full",   { OP_APP_DATA }, 1, true },
    +    };
    +
    +    BasStringT *consts[1];
    +
    +    consts[0] = basStringNew("g", 1);
    +
    +    for (int32_t i = 0; i < (int32_t)(sizeof(guards) / sizeof(guards[0])); i++) {
    +        const GuardCaseT *g = &guards[i];
    +        BasModuleT        module;
    +
    +        memset(&module, 0, sizeof(module));
    +        module.code       = (uint8_t *)g->code;
    +        module.codeLen    = g->len;
    +        module.constants  = consts;
    +        module.constCount = 1;
    +
    +        BasVmT *vm = basVmCreate();
    +
    +        basVmLoadModule(vm, &module);
    +        vm->callStack[0].localCount = 0;
    +        vm->callDepth               = 1;
    +
    +        if (g->prefill) {
    +            for (int32_t s = 0; s < BAS_VM_STACK_SIZE; s++) {
    +                vm->stack[s] = basValInteger(0);
    +            }
    +
    +            vm->sp = BAS_VM_STACK_SIZE;
    +        }
    +
    +        BasVmResultE r = basVmRun(vm);
    +        char         detail[128];
    +
    +        snprintf(detail, sizeof(detail), "guard %s r=%d", g->name, (int32_t)r);
    +        expectTrue("opcode-guard", r != BAS_VM_HALTED && r != BAS_VM_OK, detail);
    +        basVmDestroy(vm);
    +    }
    +
    +    basStringUnref(consts[0]);
    +
    +    // Targeted operand guards: bad variable/field indices, wrong operand
    +    // types, FOR bookkeeping, and the CALL slot window.
    +    HandCodeT h;
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_LOAD_LOCAL);
    +    handEmitU16(&h, 60);
    +    testHandRunError("guard-load-local-bad", &h, NULL, 0, 0, "Invalid local");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_TRUE);
    +    handEmit8(&h, OP_STORE_LOCAL);
    +    handEmitU16(&h, 60);
    +    testHandRunError("guard-store-local-bad", &h, NULL, 0, 0, "Invalid local");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_LOAD_GLOBAL);
    +    handEmitU16(&h, BAS_VM_MAX_GLOBALS);
    +    testHandRunError("guard-load-global-bad", &h, NULL, 0, 0, "Invalid global");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_TRUE);
    +    handEmit8(&h, OP_STORE_GLOBAL);
    +    handEmitU16(&h, BAS_VM_MAX_GLOBALS);
    +    testHandRunError("guard-store-global-bad", &h, NULL, 0, 0, "Invalid global");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_LOCAL_ADDR);
    +    handEmitU16(&h, 60);
    +    testHandRunError("guard-local-addr-bad", &h, NULL, 0, 0, "Invalid local");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_GLOBAL_ADDR);
    +    handEmitU16(&h, BAS_VM_MAX_GLOBALS);
    +    testHandRunError("guard-global-addr-bad", &h, NULL, 0, 0, "Invalid global");
    +
    +    // FOR_NEXT with no FOR frame, then with a mismatched variable.
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_FOR_NEXT);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, SCOPE_LOCAL);
    +    handEmit16(&h, 0);
    +    testHandRunError("guard-next-without-for", &h, NULL, 0, 1, "NEXT without FOR");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_STORE_LOCAL);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 5);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_FOR_INIT);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, SCOPE_LOCAL);
    +    handEmit16(&h, 2);
    +    handEmit8(&h, OP_FOR_NEXT);
    +    handEmitU16(&h, 1);
    +    handEmit8(&h, SCOPE_LOCAL);
    +    handEmit16(&h, 0);
    +    testHandRunError("guard-next-mismatch", &h, NULL, 0, 2, "NEXT");
    +
    +    // FOR nesting one past the VM cap; the compiler enforces its own
    +    // limit, so push the frames by hand.
    +    {
    +        BasModuleT module;
    +        uint8_t    forCode[GUARD_FOR_TOO_DEEP * 18];
    +        int32_t    fpos = 0;
    +
    +        // Each frame needs its own loop variable: a repeated variable
    +        // replaces the live frame instead of nesting deeper.
    +        for (int32_t d = 0; d < GUARD_FOR_TOO_DEEP; d++) {
    +            forCode[fpos++] = OP_PUSH_INT16;
    +            forCode[fpos++] = 1;
    +            forCode[fpos++] = 0;
    +            forCode[fpos++] = OP_STORE_GLOBAL;
    +            forCode[fpos++] = (uint8_t)d;
    +            forCode[fpos++] = 0;
    +            forCode[fpos++] = OP_PUSH_INT16;
    +            forCode[fpos++] = 9;
    +            forCode[fpos++] = 0;
    +            forCode[fpos++] = OP_PUSH_INT16;
    +            forCode[fpos++] = 1;
    +            forCode[fpos++] = 0;
    +            forCode[fpos++] = OP_FOR_INIT;
    +            forCode[fpos++] = (uint8_t)d;
    +            forCode[fpos++] = 0;
    +            forCode[fpos++] = SCOPE_GLOBAL;
    +            forCode[fpos++] = 0;
    +            forCode[fpos++] = 0;
    +        }
    +
    +        memset(&module, 0, sizeof(module));
    +        module.code        = forCode;
    +        module.codeLen     = fpos;
    +        module.globalCount = GUARD_FOR_TOO_DEEP;
    +
    +        BasVmT *vm = basVmCreate();
    +
    +        basVmLoadModule(vm, &module);
    +        vm->callDepth = 1;
    +
    +        BasVmResultE r = basVmRun(vm);
    +
    +        expectTrue("guard-for-too-deep", r != BAS_VM_HALTED && strstr(basVmGetError(vm), "nesting too deep") != NULL, basVmGetError(vm));
    +        basVmDestroy(vm);
    +    }
    +
    +    // RET and RET_VAL on the outermost frame end the program.
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_RET);
    +    testHandRun("guard-ret-top-frame", &h, NULL, 0, 0, "");
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_TRUE);
    +    handEmit8(&h, OP_RET_VAL);
    +    testHandRun("guard-ret-val-top-frame", &h, NULL, 0, 0, "");
    +
    +    // CALL_METHOD past the argument cap drains everything it consumed.
    +    memset(&h, 0, sizeof(h));
    +
    +    for (int32_t p = 0; p < BAS_VM_MAX_CALL_ARGS + 3; p++) {
    +        handEmit8(&h, OP_PUSH_TRUE);
    +    }
    +
    +    handEmit8(&h, OP_CALL_METHOD);
    +    handEmit8(&h, BAS_VM_MAX_CALL_ARGS + 1);
    +    testHandRunError("guard-method-too-many-args", &h, NULL, 0, 0, "Too many arguments");
    +
    +    // A mode byte no OPEN emits is refused (stack: name, channel, recLen).
    +    memset(&h, 0, sizeof(h));
    +    consts[0] = basStringNew("g", 1);
    +    handEmit8(&h, OP_PUSH_STR);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, BAS_VM_RANDOM_RECORD_SIZE);
    +    handEmit8(&h, OP_FILE_OPEN);
    +    handEmit8(&h, 9);
    +    testHandRunError("guard-bad-file-mode", &h, consts, 1, 0, "Bad file mode");
    +    basStringUnref(consts[0]);
    +
    +    // CALL with a base slot past the local window (operand layout:
    +    // uint16 addr + uint8 argc + uint8 baseSlot).
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_CALL);
    +    handEmitU16(&h, 6);
    +    handEmit8(&h, 2);
    +    handEmit8(&h, GUARD_BAD_SLOT);
    +    handEmit8(&h, OP_HALT);
    +    handEmit8(&h, OP_RET);
    +    testHandRunError("guard-call-slots", &h, NULL, 0, 0, "argument slots");
    +
    +    // Array/field type guards: non-array and non-UDT operands.
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_TRUE);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 0);
    +    handEmit8(&h, OP_LOAD_ARRAY);
    +    handEmit8(&h, 1);
    +    testHandRunError("guard-load-array-type", &h, NULL, 0, 0, "Not an array");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_TRUE);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 0);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_STORE_ARRAY);
    +    handEmit8(&h, 1);
    +    testHandRunError("guard-store-array-type", &h, NULL, 0, 0, "Not an array");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_TRUE);
    +    handEmit8(&h, OP_LBOUND);
    +    handEmit8(&h, 1);
    +    testHandRunError("guard-lbound-type", &h, NULL, 0, 0, "Not an array");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 0);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 3);
    +    handEmit8(&h, OP_DIM_ARRAY);
    +    handEmit8(&h, 1);
    +    handEmit8(&h, BAS_TYPE_INTEGER);
    +    handEmit8(&h, OP_LBOUND);
    +    handEmit8(&h, 9);
    +    testHandRunError("guard-lbound-dim", &h, NULL, 0, 0, "Invalid dimension");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_TRUE);
    +    handEmit8(&h, OP_LOAD_FIELD);
    +    handEmitU16(&h, 0);
    +    testHandRunError("guard-load-field-type", &h, NULL, 0, 0, "Not a TYPE");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_TRUE);
    +    handEmit8(&h, OP_PUSH_TRUE);
    +    handEmit8(&h, OP_STORE_FIELD);
    +    handEmitU16(&h, 0);
    +    testHandRunError("guard-store-field-type", &h, NULL, 0, 0, "Not a TYPE");
    +
    +    // Form-variable access with no form context.
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_LOAD_FORM_VAR);
    +    handEmitU16(&h, 0);
    +    testHandRunError("guard-form-var-load", &h, NULL, 0, 0, "outside form context");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_TRUE);
    +    handEmit8(&h, OP_STORE_FORM_VAR);
    +    handEmitU16(&h, 0);
    +    testHandRunError("guard-form-var-store", &h, NULL, 0, 0, "outside form context");
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_FORM_ADDR);
    +    handEmitU16(&h, 0);
    +    testHandRunError("guard-form-var-addr", &h, NULL, 0, 0, "outside form context");
    +
    +    // Ops that read the current frame fall back to frame 0 when no call
    +    // frame was primed.
    +    {
    +        BasModuleT module;
    +        uint8_t    frameCode[4] = { OP_LOAD_LOCAL, 0, 0, OP_HALT };
    +
    +        memset(&module, 0, sizeof(module));
    +        module.code    = frameCode;
    +        module.codeLen = (int32_t)sizeof(frameCode);
    +
    +        BasVmT *vm = basVmCreate();
    +
    +        basVmLoadModule(vm, &module);
    +
    +        BasVmResultE r = basVmRun(vm);
    +
    +        expectTrue("guard-frame-zero", r == BAS_VM_ERROR, basVmGetError(vm));
    +        basVmDestroy(vm);
    +    }
    +
    +    memset(&h, 0, sizeof(h));
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 3);
    +    handEmit8(&h, OP_PUSH_INT16);
    +    handEmit16(&h, 1);
    +    handEmit8(&h, OP_FOR_INIT);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, SCOPE_FORM);
    +    handEmit16(&h, 0);
    +    handEmit8(&h, OP_FOR_NEXT);
    +    handEmitU16(&h, 0);
    +    handEmit8(&h, SCOPE_FORM);
    +    handEmit16(&h, 0);
    +    testHandRunError("guard-for-form-scope", &h, NULL, 0, 0, "form variable");
    +}
    +
    +
    +static void testReleaseRoundTrip(const char *name) {
    +    const char *source =
    +        "SUB BtnOK_Click\n"
    +        "  PRINT \"clicked\"\n"
    +        "END SUB\n"
    +        "SUB Text_Change\n"
    +        "END SUB\n"
    +        "DIM h AS STRING\n"
    +        "h = \"BtnOK_Click\"\n"
    +        "PRINT \"Text\"\n"
    +        "PRINT h\n";
    +    const char *frm =
    +        "Begin Form Form1\n"
    +        "Caption = \"Demo\"\n"
    +        "Begin CommandButton BtnOK\n"
    +        "Caption = \"OK\"\n"
    +        "End\n"
    +        "Begin TextBox Text\n"
    +        "Text = \"abc\"\n"
    +        "End\n"
    +        "Begin DBGrid Grid1\n"
    +        "DataSource = \"BtnOK\"\n"
    +        "End\n"
    +        "End\n"
    +        "Sub Form_Load ()\nEnd Sub\n";
    +    BasParserT parser;
    +
    +    basParserInit(&parser, source, (int32_t)strlen(source));
    +
    +    if (!basParse(&parser)) {
    +        reportFail(name, parser.error);
    +        basParserFree(&parser);
    +        return;
    +    }
    +
    +    BasModuleT *mod = basParserBuildModule(&parser);
    +    basParserFree(&parser);
    +
    +    basStripModule(mod);
    +
    +    const char *frmTexts[1] = { frm };
    +    int32_t     frmLens[1]  = { (int32_t)strlen(frm) };
    +    BasObfFrmT  obf[1];
    +
    +    basObfuscateNames(mod, frmTexts, frmLens, 1, obf);
    +    basCompactBytecode(mod);
    +
    +    char detail[512] = "";
    +    bool ok          = true;
    +
    +    // The button handler was renamed (BtnOK -> C?) and the constant that
    +    // names it as a SetEvent target followed the rename.
    +    const BasProcEntryT *btnProc  = NULL;
    +    const BasProcEntryT *textProc = basModuleFindProc(mod, "Text_Change");
    +
    +    for (int32_t i = 0; i < mod->procCount; i++) {
    +        if (strstr(mod->procs[i].name, "_Click")) {
    +            btnProc = &mod->procs[i];
    +        }
    +    }
    +
    +    if (!btnProc || strncmp(btnProc->name, "BtnOK", 5) == 0) {
    +        snprintf(detail, sizeof(detail), "BtnOK_Click was not obfuscated");
    +        ok = false;
    +    } else if (!basModuleFindProc(mod, btnProc->name)) {
    +        snprintf(detail, sizeof(detail), "renamed handler not findable");
    +        ok = false;
    +    } else if (!textProc) {
    +        snprintf(detail, sizeof(detail), "Text_Change must keep its name (Text is a property key)");
    +        ok = false;
    +    }
    +
    +    bool sawHandlerConst = false;
    +    bool sawTextConst    = false;
    +
    +    for (int32_t i = 0; ok && i < mod->constCount; i++) {
    +        if (btnProc && strcmp(mod->constants[i]->data, btnProc->name) == 0) {
    +            sawHandlerConst = true;
    +        }
    +
    +        if (strcmp(mod->constants[i]->data, "Text") == 0) {
    +            sawTextConst = true;
    +        }
    +    }
    +
    +    if (ok && (!sawHandlerConst || !sawTextConst)) {
    +        snprintf(detail, sizeof(detail), "constants: handler=%d text=%d", (int)sawHandlerConst, (int)sawTextConst);
    +        ok = false;
    +    }
    +
    +    // The .frm keeps the type token and the property key, renames the
    +    // button both in its Begin line and where a value names it, and drops
    +    // the trailing code section.
    +    // obf[].data is a length-counted buffer with no terminator, so copy
    +    // it before searching.
    +    if (ok && obf[0].data) {
    +        char *text = malloc((size_t)obf[0].len + 1);
    +
    +        memcpy(text, obf[0].data, (size_t)obf[0].len);
    +        text[obf[0].len] = '\0';
    +
    +        if (!strstr(text, "Begin TextBox Text\n") || !strstr(text, "Text = \"abc\"") || strstr(text, "BtnOK") || strstr(text, "Form_Load") || !strstr(text, "DataSource = \"C")) {
    +            snprintf(detail, sizeof(detail), "frm rewrite wrong:\n%s", text);
    +            ok = false;
    +        }
    +
    +        free(text);
    +    } else if (ok) {
    +        snprintf(detail, sizeof(detail), "no obfuscated frm produced");
    +        ok = false;
    +    }
    +
    +    // Serialize, deserialize, run.
    +    if (ok) {
    +        int32_t  len  = 0;
    +        uint8_t *data = basModuleSerialize(mod, &len);
    +        BasModuleT *back = data ? basModuleDeserialize(data, len) : NULL;
    +
    +        free(data);
    +
    +        if (!back) {
    +            snprintf(detail, sizeof(detail), "serialize round trip failed");
    +            ok = false;
    +        } else {
    +            BasVmT  *vm = basVmCreate();
    +            CaptureT cap;
    +            char     expected[256];
    +
    +            captureReset(&cap);
    +            basVmLoadModule(vm, back);
    +            basVmSetPrintCallback(vm, capturePrint, &cap);
    +
    +            BasVmResultE rc = basVmRun(vm);
    +
    +            snprintf(expected, sizeof(expected), "Text\n%s\n", btnProc->name);
    +
    +            if (rc != BAS_VM_HALTED || strcmp(cap.buf, expected) != 0) {
    +                snprintf(detail, sizeof(detail), "rc=%d out=[%s] expected=[%s]", (int)rc, cap.buf, expected);
    +                ok = false;
    +            }
    +
    +            basVmDestroy(vm);
    +            basModuleFree(back);
    +        }
    +    }
    +
    +    free(obf[0].data);
    +    basModuleFree(mod);
    +
    +    if (ok) {
    +        reportPass(name);
    +    } else {
    +        reportFail(name, detail);
    +    }
    +}
    +
    +
    +// TEST_RUNTIME_ERROR -- expect runtime error; optional substring match
    +// on the error message.
    +static void testRuntimeError(const char *name, const char *source, const char *needle) {
    +    char out[CAPTURE_MAX];
    +    char err[256] = "";
    +
    +    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));
    +
    +    if (rc <= 0) {
    +        char detail[256];
    +        snprintf(detail, sizeof(detail), "expected runtime error; got rc=%d output=[%s]", (int)rc, out);
    +        reportFail(name, detail);
    +        return;
    +    }
    +
    +    if (needle && needle[0] && strstr(err, needle) == NULL) {
    +        char detail[512];
    +        snprintf(detail, sizeof(detail), "error [%s] did not contain [%s]", err, needle);
    +        reportFail(name, detail);
    +        return;
    +    }
    +
    +    reportPass(name);
    +}
    +
    +
    +// TEST_SUB_EQ -- compile, run module-level code, then invoke a named
    +// SUB through basVmCallSub (the path used by event handlers), and
    +// compare captured output.
    +// Serialization: a data pool covering every value type must round trip;
    +// corrupt headers, counts, types and truncations must all be rejected.
    +static void testSerializeCorrupt(const char *name) {
    +    BasStringT     *consts[2];
    +    BasValueT       pool[6];
    +    BasProcEntryT   proc;
    +    BasFormVarInfoT formVar;
    +    BasGlobalInitT  init;
    +    BasModuleT      module;
    +    uint8_t         haltCode[1] = { OP_HALT };
    +
    +    consts[0] = basStringNew("alpha", 5);
    +    consts[1] = basStringNew("", 0);
    +
    +    memset(pool, 0, sizeof(pool));
    +    pool[0].type    = BAS_TYPE_INTEGER;
    +    pool[0].intVal  = -7;
    +    pool[1].type    = BAS_TYPE_LONG;
    +    pool[1].longVal = 100000;
    +    pool[2].type    = BAS_TYPE_SINGLE;
    +    pool[2].sngVal  = 1.5f;
    +    pool[3].type    = BAS_TYPE_DOUBLE;
    +    pool[3].dblVal  = 2.25;
    +    pool[4].type    = BAS_TYPE_STRING;
    +    pool[4].strVal  = basStringNew("datum", 5);
    +    pool[5].type    = BAS_TYPE_BOOLEAN;
    +    pool[5].boolVal = -1;
    +
    +    memset(&proc, 0, sizeof(proc));
    +    snprintf(proc.name, sizeof(proc.name), "DoIt");
    +    snprintf(proc.formName, sizeof(proc.formName), "Form1");
    +    proc.paramCount = 1;
    +    proc.localCount = 2;
    +    proc.returnType = BAS_TYPE_LONG;
    +    proc.isFunction = true;
    +
    +    memset(&formVar, 0, sizeof(formVar));
    +    snprintf(formVar.formName, sizeof(formVar.formName), "Form1");
    +    formVar.varCount     = 3;
    +    formVar.initCodeAddr = -1;
    +
    +    init.index    = 0;
    +    init.dataType = BAS_TYPE_STRING;
    +
    +    memset(&module, 0, sizeof(module));
    +    module.code             = haltCode;
    +    module.codeLen          = 1;
    +    module.constants        = consts;
    +    module.constCount       = 2;
    +    module.globalCount      = 1;
    +    module.dataPool         = pool;
    +    module.dataCount        = 6;
    +    module.procs            = &proc;
    +    module.procCount        = 1;
    +    module.formVarInfo      = &formVar;
    +    module.formVarInfoCount = 1;
    +    module.globalInits      = &init;
    +    module.globalInitCount  = 1;
    +
    +    int32_t  len  = 0;
    +    uint8_t *data = basModuleSerialize(&module, &len);
    +
    +    if (!data || len <= 0) {
    +        reportFail(name, "serialize failed");
    +        basStringUnref(consts[0]);
    +        basStringUnref(consts[1]);
    +        basStringUnref(pool[4].strVal);
    +        return;
    +    }
    +
    +    BasModuleT *back = basModuleDeserialize(data, len);
    +    bool        ok   = back != NULL;
    +    char        detail[256];
    +
    +    if (ok) {
    +        ok = back->constCount == 2 && strcmp(back->constants[0]->data, "alpha") == 0 && back->dataCount == 6 && back->dataPool[0].intVal == -7 && back->dataPool[1].longVal == 100000 && back->dataPool[2].sngVal == 1.5f && back->dataPool[3].dblVal == 2.25 && strcmp(back->dataPool[4].strVal->data, "datum") == 0 && back->dataPool[5].boolVal == -1 && back->procCount == 1 && strcmp(back->procs[0].name, "DoIt") == 0 && back->procs[0].isFunction && back->formVarInfoCount == 1 && back->formVarInfo[0].varCount == 3 && back->globalInitCount == 1 && back->globalInits[0].dataType == BAS_TYPE_STRING;
    +    }
    +
    +    snprintf(detail, sizeof(detail), "back=%p", (void *)back);
    +    expectTrue(name, ok, detail);
    +    basModuleFree(back);
    +
    +    uint8_t *bad = malloc(len);
    +
    +    if (!bad) {
    +        reportFail(name, "malloc failed");
    +        free(data);
    +        basStringUnref(consts[0]);
    +        basStringUnref(consts[1]);
    +        basStringUnref(pool[4].strVal);
    +        return;
    +    }
    +
    +    // Bad magic.
    +    memcpy(bad, data, len);
    +    bad[0] ^= 0xFF;
    +    expectTrue("serialize-bad-magic", basModuleDeserialize(bad, len) == NULL, "bad magic accepted");
    +    memcpy(bad, data, len);
    +
    +    // Bad version.
    +    bad[SER_OFF_VERSION] ^= 0xFF;
    +    expectTrue("serialize-bad-version", basModuleDeserialize(bad, len) == NULL, "bad version accepted");
    +    memcpy(bad, data, len);
    +
    +    // Negative header counts, one field at a time.  globalCount has no
    +    // payload to bound-check here (the VM refuses it at load), so only
    +    // the payload-bearing counts appear in the oversized battery below.
    +    static const int32_t negOffsets[]  = { SER_OFF_GLOBALS, SER_OFF_CODELEN, SER_OFF_CONSTS, SER_OFF_DATA, SER_OFF_PROCS };
    +    static const int32_t hugeOffsets[] = { SER_OFF_CODELEN, SER_OFF_CONSTS, SER_OFF_DATA, SER_OFF_PROCS };
    +
    +    for (int32_t i = 0; i < (int32_t)(sizeof(negOffsets) / sizeof(negOffsets[0])); i++) {
    +        int32_t neg = -1;
    +
    +        memcpy(bad + negOffsets[i], &neg, sizeof(neg));
    +        snprintf(detail, sizeof(detail), "negative count at offset %d accepted", (int32_t)negOffsets[i]);
    +        expectTrue("serialize-negative-count", basModuleDeserialize(bad, len) == NULL, detail);
    +        memcpy(bad, data, len);
    +    }
    +
    +    for (int32_t i = 0; i < (int32_t)(sizeof(hugeOffsets) / sizeof(hugeOffsets[0])); i++) {
    +        int32_t huge = INT32_MAX / 2;
    +
    +        memcpy(bad + hugeOffsets[i], &huge, sizeof(huge));
    +        snprintf(detail, sizeof(detail), "oversized count at offset %d accepted", (int32_t)hugeOffsets[i]);
    +        expectTrue("serialize-oversized-count", basModuleDeserialize(bad, len) == NULL, detail);
    +        memcpy(bad, data, len);
    +    }
    +
    +    // A non-scalar data pool type byte desynchronizes the stream.
    +    bad[SER_HEADER_LEN + 1] = SER_BAD_TYPE;
    +    expectTrue("serialize-bad-data-type", basModuleDeserialize(bad, len) == NULL, "bad data type accepted");
    +
    +    // Every truncation point must be rejected, never crash.
    +    bool truncOk = true;
    +
    +    memcpy(bad, data, len);
    +    snprintf(detail, sizeof(detail), "all truncations rejected");
    +
    +    for (int32_t cut = 0; cut < len; cut++) {
    +        if (basModuleDeserialize(bad, cut) != NULL) {
    +            snprintf(detail, sizeof(detail), "truncation at %d accepted", (int32_t)cut);
    +            truncOk = false;
    +            break;
    +        }
    +    }
    +
    +    expectTrue("serialize-truncations", truncOk, detail);
    +
    +    free(bad);
    +    free(data);
    +    basStringUnref(consts[0]);
    +    basStringUnref(consts[1]);
    +    basStringUnref(pool[4].strVal);
    +
    +    // A data pool slot holding a non-serializable type is written as a
    +    // placeholder; the reader then rejects the stream on the type byte.
    +    BasValueT  objPool[1];
    +    BasModuleT objModule;
    +
    +    memset(objPool, 0, sizeof(objPool));
    +    objPool[0].type = BAS_TYPE_OBJECT;
    +
    +    memset(&objModule, 0, sizeof(objModule));
    +    objModule.code      = haltCode;
    +    objModule.codeLen   = 1;
    +    objModule.dataPool  = objPool;
    +    objModule.dataCount = 1;
    +
    +    len  = 0;
    +    data = basModuleSerialize(&objModule, &len);
    +    expectTrue("serialize-object-placeholder", data != NULL && basModuleDeserialize(data, len) == NULL, "object slot round-tripped");
    +    free(data);
    +}
    +
    +
    +static void testSubEq(const char *name, const char *source, const char *subName, const char *expected) {
    +    char out[CAPTURE_MAX];
    +    char err[256] = "";
    +
    +    int32_t rc = runSubAndCapture(source, subName, out, sizeof(out), err, sizeof(err));
    +
    +    if (rc < 0) {
    +        char detail[512];
    +        snprintf(detail, sizeof(detail), "setup error: %s", err);
    +        reportFail(name, detail);
    +        return;
    +    }
    +
    +    if (rc != 0) {
    +        char detail[512];
    +        snprintf(detail, sizeof(detail), "SUB returned false (unhandled error): %s", err);
    +        reportFail(name, detail);
    +        return;
    +    }
    +
    +    if (strcmp(out, expected) != 0) {
    +        reportFailDiff(name, expected, out);
    +        return;
    +    }
    +
    +    reportPass(name);
    +}
    +
    +
    +// TEST_UI_EQ -- testEq with the recording UI stubs installed
    +static void testUiEq(const char *name, const char *source, const char *expected) {
    +    sRunUi = &sUiStubs;
    +    testEq(name, source, expected);
    +    sRunUi = NULL;
    +}
    +
    +
    +// Direct values.c API edges: refused allocations, substring/compare
    +// boundaries, numeric parsing forms, deep UDT clones and reference
    +// counting through basValCopy.
    +static void testValuesApi(void) {
    +    // Arrays: bad bounds and overflowing element counts are refused.
    +    int32_t lb[2] = { 0, 0 };
    +    int32_t ub[2] = { -1, 0 };
    +
    +    expectTrue("values-array-empty-dim", basArrayNew(1, lb, ub, BAS_TYPE_INTEGER) == NULL, "ubound < lbound accepted");
    +
    +    ub[0] = INT32_MAX - 1;
    +    ub[1] = INT32_MAX - 1;
    +    expectTrue("values-array-overflow", basArrayNew(2, lb, ub, BAS_TYPE_INTEGER) == NULL, "overflowing element count accepted");
    +
    +    // A STRING array seeds every element with an empty string.
    +    ub[0] = 2;
    +    ub[1] = 2;
    +
    +    BasArrayT *arr = basArrayNew(2, lb, ub, BAS_TYPE_STRING);
    +
    +    expectTrue("values-array-string-init", arr != NULL && arr->totalElements == 9 && arr->elements[8].strVal != NULL, "string elements not seeded");
    +
    +    // Out-of-bounds index computation.
    +    int32_t idx[2] = { 3, 0 };
    +
    +    expectTrue("values-array-index-oob", arr && basArrayIndex(arr, idx, 2) == -1, "out-of-bounds index accepted");
    +
    +    // basValCopy on an element reference counts the owning array.
    +    if (arr) {
    +        BasValueT ref;
    +
    +        memset(&ref, 0, sizeof(ref));
    +        ref.type        = BAS_TYPE_ELEM_REF;
    +        ref.elemRef.arr = arr;
    +        ref.elemRef.idx = 0;
    +
    +        BasValueT copy = basValCopy(ref);
    +
    +        expectTrue("values-elem-ref-copy", arr->refCount == 2, "elem ref copy did not count the array");
    +        basValRelease(©);
    +        basArrayUnref(arr);
    +    }
    +
    +    // Substrings: clamped start, past-the-end start, negative length.
    +    BasStringT *s   = basStringNew("abcdef", 6);
    +    BasStringT *sub = basStringSub(s, -2, 3);
    +
    +    expectTrue("values-substr-neg-start", sub && strcmp(sub->data, "abc") == 0, "negative start not clamped");
    +    basStringUnref(sub);
    +
    +    sub = basStringSub(s, 10, 3);
    +    expectTrue("values-substr-past-end", sub && sub->len == 0, "past-the-end start not empty");
    +    basStringUnref(sub);
    +
    +    sub = basStringSub(s, 4, -1);
    +    expectTrue("values-substr-neg-len", sub && strcmp(sub->data, "ef") == 0, "negative length not to-end");
    +    basStringUnref(sub);
    +
    +    // basStringAlloc clamps a zero capacity.
    +    BasStringT *alloc = basStringAlloc(0);
    +
    +    expectTrue("values-string-alloc-min", alloc != NULL && alloc->cap >= 1, "zero-cap alloc failed");
    +    basStringUnref(alloc);
    +
    +    // Case-insensitive compare: equal, prefix, letter difference.
    +    BasStringT *upper     = basStringNew("ABC", 3);
    +    BasStringT *lower     = basStringNew("abc", 3);
    +    BasStringT *lowerLong = basStringNew("abcd", 4);
    +
    +    expectTrue("values-compare-ci", basStringCompareCI(upper, lowerLong) < 0 && basStringCompareCI(lowerLong, upper) > 0, "prefix compare wrong");
    +    expectTrue("values-compare-ci-eq", basStringCompareCI(upper, lower) == 0, "case-insensitive equality failed");
    +    basStringUnref(upper);
    +    basStringUnref(lower);
    +    basStringUnref(lowerLong);
    +    basStringUnref(s);
    +
    +    // Number parsing: D exponents, explicit signs, and garbage.
    +    expectTrue("values-parse-d-exponent", basParseNumber("1D3") == 1000.0, "D exponent not honoured");
    +    expectTrue("values-parse-signed-exp", basParseNumber("2.5e+2") == 250.0, "signed exponent not honoured");
    +    expectTrue("values-parse-garbage", basParseNumber("zz") == 0.0, "garbage did not parse to 0");
    +
    +    // Truthiness across types.
    +    expectTrue("values-truthy-single", basValIsTruthy(basValSingle(0.5f)) && !basValIsTruthy(basValSingle(0.0f)), "single truthiness wrong");
    +    expectTrue("values-truthy-double", basValIsTruthy(basValDouble(0.25)) && !basValIsTruthy(basValDouble(0.0)), "double truthiness wrong");
    +
    +    BasValueT boolVal = basValToBool(basValDouble(2.5));
    +
    +    expectTrue("values-to-bool", boolVal.type == BAS_TYPE_BOOLEAN && boolVal.boolVal == -1, "to-bool wrong");
    +
    +    // Formatting: booleans and host objects.
    +    BasStringT *fmt = basValFormatString(basValBool(true));
    +
    +    expectTrue("values-format-bool", fmt && strcmp(fmt->data, "True") == 0, "True format wrong");
    +    basStringUnref(fmt);
    +
    +    fmt = basValFormatString(basValObject(NULL));
    +    expectTrue("values-format-object", fmt && fmt->len == 0, "object format not empty");
    +    basStringUnref(fmt);
    +
    +    // A STRING value with a NULL payload still converts to a string type.
    +    BasValueT nullStr;
    +
    +    memset(&nullStr, 0, sizeof(nullStr));
    +    nullStr.type = BAS_TYPE_STRING;
    +
    +    BasValueT conv = basValToString(nullStr);
    +
    +    expectTrue("values-null-string-conv", conv.type == BAS_TYPE_STRING, "NULL string conversion wrong");
    +    basValRelease(&conv);
    +
    +    // Rounding with range enforcement.
    +    int32_t rounded = 0;
    +
    +    expectTrue("values-round-in-range", basValRoundToInt32(basValDouble(2.5), 0, 10, &rounded) && rounded == 2, "banker's rounding wrong");
    +    expectTrue("values-round-out-of-range", !basValRoundToInt32(basValDouble(99.0), 0, 10, &rounded), "out-of-range accepted");
    +
    +    // Conversion clamps: values past the target range saturate.
    +    BasValueT conv2 = basValToInteger(basValDouble(1e10));
    +
    +    expectTrue("values-to-integer-clamp", conv2.intVal == INT16_MAX, "positive clamp wrong");
    +    conv2 = basValToInteger(basValDouble(-1e10));
    +    expectTrue("values-to-integer-clamp-neg", conv2.intVal == INT16_MIN, "negative clamp wrong");
    +    conv2 = basValToLong(basValDouble(1e12));
    +    expectTrue("values-to-long-clamp", conv2.longVal == INT32_MAX, "long clamp wrong");
    +    conv2 = basValToSingle(basValLong(2));
    +    expectTrue("values-to-single", conv2.sngVal == 2.0f, "to-single wrong");
    +
    +    // NULL-safe reference helpers and empty-string parsing.
    +    expectTrue("values-array-ref-null", basArrayRef(NULL) == NULL, "NULL array ref wrong");
    +    expectTrue("values-parse-empty", basParseNumber("") == 0.0, "empty parse wrong");
    +
    +    // Deep UDT clone: nested UDT fields are copies, not references.
    +    BasUdtT *inner = basUdtNew(1, 1);
    +    BasUdtT *outer = basUdtNew(2, 2);
    +
    +    if (inner && outer) {
    +        inner->fields[0]        = basValLong(7);
    +        outer->fields[0].type   = BAS_TYPE_UDT;
    +        outer->fields[0].udtVal = inner;
    +        outer->fields[1]        = basValStringFromC("tag");
    +
    +        BasUdtT *clone = basUdtClone(outer);
    +
    +        expectTrue("values-udt-clone", clone != NULL && clone->fields[0].udtVal != inner && clone->fields[0].udtVal->fields[0].longVal == 7 && strcmp(clone->fields[1].strVal->data, "tag") == 0, "clone not deep");
    +
    +        if (clone) {
    +            basUdtRef(clone);
    +            basUdtUnref(clone);
    +            basUdtUnref(clone);
    +        }
    +
    +        basUdtUnref(outer);
    +    } else {
    +        reportFail("values-udt-clone", "allocation failed");
    +        basUdtFree(inner);
    +        basUdtFree(outer);
    +    }
    +}
    +
    +
    +static BasValueT uiStubCallMethod(void *ctx, void *ctrlRef, const char *methodName, BasValueT *args, int32_t argc) {
    +    char line[CAPTURE_MAX];
    +
    +    (void)ctx;
    +    (void)args;
    +    snprintf(line, sizeof(line), "call %s.%s/%d\n", uiStubFormName(ctrlRef), methodName, (int)argc);
    +    captureAppend(sUiCap, line);
    +    return basValInteger((int16_t)argc);
    +}
    +
    +
    +static void *uiStubCreateCtrl(void *ctx, void *formRef, const char *typeName, const char *ctrlName) {
    +    char line[CAPTURE_MAX];
    +    char path[UI_STUB_REF_LEN];
    +
    +    (void)ctx;
    +    snprintf(line, sizeof(line), "new %s %s.%s\n", typeName, uiStubFormName(formRef), ctrlName);
    +    captureAppend(sUiCap, line);
    +    snprintf(path, sizeof(path), "%s.%s", uiStubFormName(formRef), ctrlName);
    +    return uiStubRef(path);
    +}
    +
    +
    +static void *uiStubCreateCtrlEx(void *ctx, void *formRef, const char *typeName, const char *ctrlName, void *parentRef) {
    +    char line[CAPTURE_MAX];
    +    char path[UI_STUB_REF_LEN];
    +
    +    (void)ctx;
    +    snprintf(line, sizeof(line), "newex %s %s.%s in %s\n", typeName, uiStubFormName(formRef), ctrlName, parentRef ? (const char *)parentRef : "root");
    +    captureAppend(sUiCap, line);
    +    snprintf(path, sizeof(path), "%s.%s", uiStubFormName(formRef), ctrlName);
    +    return uiStubRef(path);
    +}
    +
    +
    +static void *uiStubCreateForm(void *ctx, const char *formName, int32_t width, int32_t height) {
    +    (void)ctx;
    +    (void)width;
    +    (void)height;
    +    return uiStubRef(formName);
    +}
    +
    +
    +static void *uiStubFindCtrl(void *ctx, void *formRef, const char *ctrlName) {
    +    char path[UI_STUB_REF_LEN];
    +
    +    (void)ctx;
    +    snprintf(path, sizeof(path), "%s.%s", uiStubFormName(formRef), ctrlName);
    +    return uiStubRef(path);
    +}
    +
    +
    +static void *uiStubFindCtrlIdx(void *ctx, void *formRef, const char *ctrlName, int32_t index) {
    +    char path[UI_STUB_REF_LEN];
    +
    +    (void)ctx;
    +    snprintf(path, sizeof(path), "%s.%s(%d)", uiStubFormName(formRef), ctrlName, (int)index);
    +    return uiStubRef(path);
    +}
    +
    +
    +static const char *uiStubFormName(void *formRef) {
    +    return formRef ? (const char *)formRef : "Me";
    +}
    +
    +
    +static BasValueT uiStubGetProp(void *ctx, void *ctrlRef, const char *propName) {
    +    char path[UI_STUB_REF_LEN];
    +
    +    (void)ctx;
    +    snprintf(path, sizeof(path), "%s.%s", uiStubFormName(ctrlRef), propName);
    +    return basValStringFromC(path);
    +}
    +
    +
    +static void uiStubHideForm(void *ctx, void *formRef) {
    +    char line[CAPTURE_MAX];
    +
    +    (void)ctx;
    +    snprintf(line, sizeof(line), "hide %s\n", uiStubFormName(formRef));
    +    captureAppend(sUiCap, line);
    +}
    +
    +
    +static BasStringT *uiStubInputBox(void *ctx, const char *prompt, const char *title, const char *defaultText) {
    +    char line[CAPTURE_MAX];
    +
    +    (void)ctx;
    +    snprintf(line, sizeof(line), "inputbox %s|%s|%s\n", prompt, title, defaultText);
    +    captureAppend(sUiCap, line);
    +    return basStringNew("typed", 5);
    +}
    +
    +
    +static void *uiStubLoadForm(void *ctx, const char *formName) {
    +    (void)ctx;
    +    return uiStubRef(formName);
    +}
    +
    +
    +static int32_t uiStubMsgBox(void *ctx, const char *message, int32_t flags, const char *title) {
    +    char line[CAPTURE_MAX];
    +
    +    (void)ctx;
    +    snprintf(line, sizeof(line), "msgbox [%s] f=%d [%s]\n", message, (int32_t)flags, title);
    +    captureAppend(sUiCap, line);
    +    return 6;
    +}
    +
    +
    +static void *uiStubRef(const char *text) {
    +    char *ref = sUiRefs[sUiRefNext];
    +
    +    sUiRefNext = (sUiRefNext + 1) % UI_STUB_REF_COUNT;
    +    snprintf(ref, UI_STUB_REF_LEN, "%s", text);
    +    return ref;
    +}
    +
    +
    +static void uiStubRemoveCtrl(void *ctx, void *formRef, const char *ctrlName) {
    +    char line[CAPTURE_MAX];
    +
    +    (void)ctx;
    +    snprintf(line, sizeof(line), "removectrl %s.%s\n", uiStubFormName(formRef), ctrlName);
    +    captureAppend(sUiCap, line);
    +}
    +
    +
    +static void uiStubSetEvent(void *ctx, void *ctrlRef, const char *eventName, const char *handlerName) {
    +    char line[CAPTURE_MAX];
    +
    +    (void)ctx;
    +    snprintf(line, sizeof(line), "setevent %s %s->%s\n", uiStubFormName(ctrlRef), eventName, handlerName);
    +    captureAppend(sUiCap, line);
    +}
    +
    +
    +static void uiStubSetProp(void *ctx, void *ctrlRef, const char *propName, BasValueT value) {
    +    char      line[CAPTURE_MAX];
    +    BasValueT sv = basValToString(value);
    +
    +    (void)ctx;
    +    snprintf(line, sizeof(line), "set %s.%s=%s\n", uiStubFormName(ctrlRef), propName, sv.strVal->data);
    +    captureAppend(sUiCap, line);
    +    basValRelease(&sv);
    +}
    +
    +
    +static void uiStubShowForm(void *ctx, void *formRef, bool modal) {
    +    char line[CAPTURE_MAX];
    +
    +    (void)ctx;
    +    snprintf(line, sizeof(line), "show %s modal=%d\n", uiStubFormName(formRef), (int32_t)modal);
    +    captureAppend(sUiCap, line);
    +}
    +
    +
    +static void uiStubUnloadForm(void *ctx, void *formRef) {
    +    char line[CAPTURE_MAX];
    +
    +    (void)ctx;
    +    snprintf(line, sizeof(line), "unload %s\n", uiStubFormName(formRef));
    +    captureAppend(sUiCap, line);
    +}
    +
     
     int main(void) {
         printf("DVX BASIC Regression Suite\n");
    @@ -2198,14 +4449,22 @@ int main(void) {
             "PRINT a(0)\n",
             "0 \n");
     
    -    // ERASE behavior in DVX: after ERASE a, accessing a(0) errors
    -    // ("Not an array").  Verify that behavior.
    -    TEST_RUNTIME_ERROR("erase-makes-not-array",
    -        "DIM a(3) AS INTEGER\n"
    +    // ERASE: a fixed array keeps its bounds and is zeroed; a dynamic
    +    // array (DIM a() / REDIM) is freed and must be REDIMmed before use.
    +    TEST_EQ("erase-fixed-zeroes",
    +        "DIM a(3) AS INTEGER\nDIM s(1) AS STRING\n"
    +        "a(0) = 7\ns(1) = \"x\"\n"
    +        "ERASE a\nERASE s\n"
    +        "PRINT a(0); UBOUND(a); LEN(s(1))\n",
    +        "0 3 0 \n");
    +
    +    TEST_RUNTIME_ERROR("erase-dynamic-frees",
    +        "DIM a() AS INTEGER\n"
    +        "REDIM a(3)\n"
             "a(0) = 7\n"
             "ERASE a\n"
             "PRINT a(0)\n",
    -        "");
    +        "Not an array");
     
         TEST_EQ("array-3d",
             "DIM a(1, 1, 1) AS INTEGER\n"
    @@ -2382,7 +4641,7 @@ int main(void) {
     
         TEST_EQ("print-comma-tab",
             "PRINT \"a\", \"b\"\n",
    -        "a\tb\n");
    +        "a             b\n");
         TEST_EQ("print-trailing-semi",
             "PRINT \"no-newline\";\nPRINT \"follow\"\n",
             "no-newlinefollow\n");
    @@ -3311,7 +5570,7 @@ int main(void) {
             "GET #1, 2, l\nCLOSE #1\n"
             "KILL \"test_suite_rnd.tmp\"\n"
             "PRINT l\n",
    -        "12 \n123456 \n");
    +        "16 \n123456 \n");
     
         TEST_RUNTIME_ERROR("file-random-bad-len",
             "OPEN \"test_suite_rnd2.tmp\" FOR RANDOM AS #1 LEN = 0\n",
    @@ -3345,10 +5604,634 @@ int main(void) {
             "PRINT LEN(SPACE$(40000))\n",
             "40000 \n");
     
    +    // ============================================================
    +    // 2026-08-26 T7 findings: PRINT USING / FORMAT$, GET/PUT of TYPEs,
    +    // BOOLEAN stores, DIM defaults, parser extensions, ERL, doc/code
    +    // alignment (ERASE, print zones, FILELEN, LOC), no form runtime.
    +    // ============================================================
    +
    +    TEST_EQ("print-using-literals",
    +        "PRINT USING \"& likes &\"; \"Scott\"; \"pizza\"\n"
    +        "PRINT USING \"Total: ###.## units\"; 7.5\n"
    +        "PRINT USING \"###.##\"; -3.14159\n"
    +        "PRINT USING \"+###\"; 42\n"
    +        "PRINT USING \"## and ##\"; 1; 2; 3\n"
    +        "PRINT USING \"_#=##\"; 5\n"
    +        "PRINT USING \"###\"; 12345\n"
    +        "PRINT USING \"$$#,##0.00\"; 42.5\n"
    +        "PRINT USING \"**#,##0.00\"; 1234.5\n"
    +        "PRINT USING \"##.##^^^^\"; 12345.678\n",
    +        "Scott likes pizza\nTotal:   7.50 units\n -3.14\n +42\n 1 and  2 3 and \n#= 5\n%12345\n    $42.50\n**1,234.50\n 1.23E+04\n");
    +
    +    TEST_RUNTIME_ERROR("print-using-field-mismatch",
    +        "PRINT USING \"###\"; \"x\"\n",
    +        "does not match");
    +
    +    TEST_EQ("format-signs-and-pads",
    +        "PRINT FORMAT$(3.14159, \"###.##\")\n"
    +        "PRINT FORMAT$(-7, \"+###\")\n"
    +        "PRINT FORMAT$(42, \"00000\")\n"
    +        "PRINT FORMAT$(-42, \"00000\")\n"
    +        "PRINT FORMAT$(5, \"#,##0.00\")\n"
    +        "PRINT FORMAT$(1234.5, \"$$#,##0.00\")\n"
    +        "PRINT FORMAT$(0.5, \"PERCENT\")\n",
    +        "  3.14\n  -7\n00042\n-00042\n    5.00\n $1,234.50\n50%\n");
    +
    +    TEST_EQ("get-put-udt-random",
    +        "TYPE Inner\n  a AS INTEGER\nEND TYPE\n"
    +        "TYPE RecT\n  id AS INTEGER\n  score AS DOUBLE\n  nm AS STRING * 6\n  tag AS STRING\n  inn AS Inner\n  ok AS BOOLEAN\nEND TYPE\n"
    +        "DIM r AS RecT\nDIM q AS RecT\n"
    +        "OPEN \"test_suite_udt.tmp\" FOR RANDOM AS #1 LEN = 32\n"
    +        "r.id = 7\nr.score = 2.5\nr.nm = \"ab\"\nr.tag = \"hello\"\nr.inn.a = 9\nr.ok = 1\n"
    +        "PUT #1, 2, r\nCLOSE #1\n"
    +        "PRINT FILELEN(\"test_suite_udt.tmp\")\n"
    +        "OPEN \"test_suite_udt.tmp\" FOR RANDOM AS #1 LEN = 32\n"
    +        "GET #1, 2, q\n"
    +        "PRINT q.id; q.score; \"[\"; q.nm; \"]\"; q.tag; q.inn.a; q.ok\n"
    +        "PRINT LOC(1)\n"
    +        "CLOSE #1\nKILL \"test_suite_udt.tmp\"\n",
    +        "64 \n7 2.5 [ab    ]hello9 True \n2 \n");
    +
    +    TEST_RUNTIME_ERROR("put-udt-longer-than-len",
    +        "TYPE RecT\n  a AS DOUBLE\n  b AS DOUBLE\nEND TYPE\n"
    +        "DIM r AS RecT\n"
    +        "OPEN \"test_suite_udt2.tmp\" FOR RANDOM AS #1 LEN = 8\n"
    +        "PUT #1, 1, r\n",
    +        "Record longer than LEN");
    +    // The error fires with the file still open, so the program never KILLs it.
    +    remove("test_suite_udt2.tmp");
    +
    +    TEST_EQ("get-put-udt-array-element",
    +        "TYPE RecT\n  id AS INTEGER\n  nm AS STRING * 4\nEND TYPE\n"
    +        "DIM a(3) AS RecT\nDIM i AS INTEGER\n"
    +        "a(1).id = 5\na(1).nm = \"ab\"\n"
    +        "OPEN \"test_suite_udta.tmp\" FOR RANDOM AS #1 LEN = 8\n"
    +        "i = 2\nPUT #1, 1, a(i - 1)\n"
    +        "PUT #1, 2, a(1).id\n"
    +        "GET #1, 1, a(i + 1)\n"
    +        "GET #1, 2, i\n"
    +        "PRINT a(3).id; \"[\"; a(3).nm; \"]\"; i; a(2).id\n"
    +        "CLOSE #1\nKILL \"test_suite_udta.tmp\"\n",
    +        "5 [ab  ]5 0 \n");
    +
    +    TEST_EQ("get-put-scalar-array-element",
    +        "DIM n(3) AS INTEGER\nDIM d(1) AS DOUBLE\nDIM s(1) AS STRING\nDIM k AS INTEGER\n"
    +        "n(1) = 42\nd(0) = 2.5\n"
    +        "OPEN \"test_suite_arr.tmp\" FOR RANDOM AS #1 LEN = 8\n"
    +        "PUT #1, 1, n(1)\nPUT #1, 2, d(0)\n"
    +        "k = 2\nGET #1, 1, n(k)\nGET #1, 2, d(k - 1)\n"
    +        "PRINT n(2); d(1); LOC(1)\n"
    +        "CLOSE #1\nKILL \"test_suite_arr.tmp\"\n"
    +        "OPEN \"test_suite_arr.tmp\" FOR BINARY AS #1\n"
    +        "PUT #1, 1, \"hello\"\ns(1) = SPACE$(3)\n"
    +        "GET #1, 2, s(1)\n"
    +        "PRINT \"[\"; s(1); \"]\"\n"
    +        "CLOSE #1\nKILL \"test_suite_arr.tmp\"\n",
    +        "42 2.5 2 \n[ell]\n");
    +
    +    TEST_RUNTIME_ERROR("get-array-element-out-of-range",
    +        "DIM n(3) AS INTEGER\n"
    +        "OPEN \"test_suite_arr2.tmp\" FOR RANDOM AS #1 LEN = 8\n"
    +        "GET #1, 1, n(7)\n",
    +        "Subscript out of range");
    +    remove("test_suite_arr2.tmp");
    +
    +    TEST_COMPILE_ERROR("get-array-without-index",
    +        "DIM n(3) AS INTEGER\n"
    +        "OPEN \"x.tmp\" FOR RANDOM AS #1 LEN = 8\n"
    +        "GET #1, 1, n\n",
    +        "array index");
    +
    +    TEST_EQ("get-put-udt-binary",
    +        "TYPE PtT\n  x AS LONG\n  y AS SINGLE\nEND TYPE\n"
    +        "DIM a AS PtT\nDIM b AS PtT\n"
    +        "a.x = 70000\na.y = 1.5\n"
    +        "OPEN \"test_suite_udt3.tmp\" FOR BINARY AS #1\n"
    +        "PUT #1, 3, a\nGET #1, 3, b\n"
    +        "PRINT b.x; b.y; LOF(1); LOC(1)\n"
    +        "CLOSE #1\nKILL \"test_suite_udt3.tmp\"\n",
    +        "70000 1.5 10 10 \n");
    +
    +    TEST_EQ("random-put-pads-and-get-skips",
    +        "DIM i AS INTEGER\nDIM j AS INTEGER\n"
    +        "OPEN \"test_suite_pad.tmp\" FOR RANDOM AS #1 LEN = 4\n"
    +        "i = 1\nPUT #1, , i\ni = 2\nPUT #1, , i\n"
    +        "PRINT LOF(1); LOC(1)\n"
    +        "GET #1, 1, i\nGET #1, , j\n"
    +        "PRINT i; j; LOC(1)\n"
    +        "CLOSE #1\nKILL \"test_suite_pad.tmp\"\n",
    +        "8 2 \n1 2 2 \n");
    +
    +    // --- Form / control object chains through the recording UI stubs ---
    +    TEST_UI_EQ("ui-three-part-expr",
    +        "PRINT Form2.Ctl.Caption\n"
    +        "PRINT Form2.Lst.Count(3)\n",
    +        "Form2.Ctl.Caption\ncall Form2.Lst.Count/1\n1 \n");
    +
    +    TEST_UI_EQ("ui-three-part-stmt",
    +        "Form2.Ctl.Caption = \"x\"\n"
    +        "Form2.Lst.Clear\n"
    +        "Form2.Lst.AddItem \"a\", 2\n",
    +        "set Form2.Ctl.Caption=x\ncall Form2.Lst.Clear/0\ncall Form2.Lst.AddItem/2\n");
    +
    +    TEST_UI_EQ("ui-form-var-three-part",
    +        "f = CreateForm(\"Dyn\", 1, 1)\n"
    +        "PRINT f.Ctl.Text\n"
    +        "f.Ctl.Text = \"t\"\n",
    +        "Dyn.Ctl.Text\nset Dyn.Ctl.Text=t\n");
    +
    +    TEST_UI_EQ("ui-me-expr",
    +        "PRINT Me.Caption\n"
    +        "PRINT Me.Ctl.Text\n"
    +        "PRINT Me.Btn(2).Caption\n"
    +        "PRINT Me.Foo(1, 2)\n"
    +        "PRINT Me.Ctl.Bar(9)\n",
    +        "Me.Caption\nMe.Ctl.Text\nMe.Btn(2).Caption\ncall Me.Foo/2\n2 \ncall Me.Ctl.Bar/1\n1 \n");
    +
    +    TEST_UI_EQ("ui-unload-me",
    +        "Unload Me\n"
    +        "Unload Form2\n",
    +        "unload Me\nunload Form2\n");
    +
    +    TEST_COMPILE_ERROR("ui-me-ctrl-idx-arity",
    +        "PRINT Me.Btn(1, 2).Caption\n",
    +        "Control array index expected");
    +
    +    TEST_EQ("boolean-store-coerces",
    +        "DIM b AS BOOLEAN\nb = 3\nPRINT b\nb = 0\nPRINT b\n"
    +        "DIM arr(1) AS BOOLEAN\narr(0) = 5\nPRINT arr(0)\n"
    +        "TYPE FlagT\n  f AS BOOLEAN\nEND TYPE\nDIM t AS FlagT\nPRINT t.f\nt.f = \"x\"\nPRINT t.f\n",
    +        "True \nFalse \nTrue \nFalse \nTrue \n");
    +
    +    TEST_EQ("dim-untyped-is-single",
    +        "DIM x\nx = 1 / 3\nPRINT x\n"
    +        "y = 1 / 3\nPRINT y\n"
    +        "DEFINT Z\nDIM z\nz = 2.6\nPRINT z\n",
    +        "0.3333333 \n0.333333333333333 \n3 \n");
    +
    +    TEST_EQ("array-element-nested-fields",
    +        "TYPE P\n  x AS INTEGER\n  s AS STRING * 3\nEND TYPE\n"
    +        "TYPE Q\n  p AS P\n  n AS INTEGER\nEND TYPE\n"
    +        "DIM a(2) AS Q\n"
    +        "a(1).p.x = 5\na(1).p.s = \"abcdef\"\na(1).n = 2\n"
    +        "PRINT a(1).p.x; a(1).n; a(1).p.s; LEN(a(0).p.s)\n"
    +        "REDIM a(4) AS Q\na(4).p.x = 8\nPRINT a(4).p.x; a(1).p.x\n",
    +        "5 2 abc3 \n8 0 \n");
    +
    +    TEST_EQ("array-parameter-byref",
    +        "DIM a(3) AS INTEGER\n"
    +        "SUB Fill(v() AS INTEGER)\n  v(2) = 42\n  REDIM v(9) AS INTEGER\n  v(9) = 7\nEND SUB\n"
    +        "FUNCTION Total(v() AS INTEGER) AS INTEGER\n  DIM i AS INTEGER\n  DIM t AS INTEGER\n  FOR i = LBOUND(v) TO UBOUND(v)\n    t = t + v(i)\n  NEXT i\n  Total = t\nEND FUNCTION\n"
    +        "a(2) = 1\nFill a()\nPRINT a(9); UBOUND(a); Total(a())\n",
    +        "7 9 7 \n");
    +
    +    TEST_EQ("swap-array-elements",
    +        "DIM a(2) AS INTEGER\na(0) = 1\na(1) = 2\n"
    +        "SWAP a(0), a(1)\nPRINT a(0); a(1)\n"
    +        "DIM s AS STRING\ns = \"x\"\nDIM t(1) AS STRING\nt(1) = \"y\"\n"
    +        "SWAP s, t(1)\nPRINT s; t(1)\n"
    +        "DIM m(1, 1) AS INTEGER\nm(0, 1) = 3\nm(1, 0) = 4\n"
    +        "SWAP m(1, 0), m(0, 1)\nPRINT m(0, 1); m(1, 0)\n"
    +        "DIM p AS INTEGER\nDIM q AS INTEGER\np = 8\nq = 9\nSWAP p, q\nPRINT p; q\n",
    +        "2 1 \nyx\n4 3 \n9 8 \n");
    +
    +    TEST_EQ("byref-keyword",
    +        "SUB Inc(BYREF n AS INTEGER)\n  n = n + 1\nEND SUB\n"
    +        "SUB Keep(BYVAL n AS INTEGER)\n  n = n + 1\nEND SUB\n"
    +        "DIM k AS INTEGER\nk = 1\nInc k\nKeep k\nPRINT k\n",
    +        "2 \n");
    +
    +    TEST_EQ("dim-dynamic-array",
    +        "DIM d() AS STRING\nREDIM d(2)\nd(2) = \"z\"\nPRINT d(2); UBOUND(d)\n"
    +        "SUB S\n  DIM e() AS INTEGER\n  REDIM e(1 TO 3)\n  e(3) = 4\n  PRINT e(3); LBOUND(e)\nEND SUB\nS\n",
    +        "z2 \n4 1 \n");
    +
    +    TEST_RUNTIME_ERROR("dim-dynamic-array-unsized",
    +        "DIM d() AS INTEGER\nPRINT d(0)\n",
    +        "Not an array");
    +
    +    TEST_EQ("erl-reports-line",
    +        "ON ERROR GOTO H\n"
    +        "PRINT 1 / 0\n"
    +        "PRINT \"after\"; ERL\n"
    +        "END\n"
    +        "H:\n"
    +        "PRINT ERR; ERL\n"
    +        "RESUME NEXT\n",
    +        "11 2 \nafter0 \n");
    +
    +    TEST_EQ("print-zones-and-tab",
    +        "PRINT \"abcdefghijklmnop\", \"x\"\n"
    +        "PRINT \"abc\"; TAB(2); \"y\"\n"
    +        "PRINT 1, 2\n"
    +        "PRINT \"ab\"; TAB(5); \"c\"\n",
    +        "abcdefghijklmnop            x\nabc\n y\n1             2 \nab  c\n");
    +
    +    TEST_EQ("filelen-missing-is-error-53",
    +        "ON ERROR GOTO H\n"
    +        "PRINT FILELEN(\"test_suite_nope.tmp\")\n"
    +        "END\n"
    +        "H:\n"
    +        "PRINT ERR\n"
    +        "END\n",
    +        "53 \n");
    +
    +    TEST_EQ("loc-binary-last-byte",
    +        "OPEN \"test_suite_loc.tmp\" FOR BINARY AS #1\n"
    +        "PRINT LOC(1)\n"
    +        "PUT #1, 1, \"ABCDE\"\n"
    +        "PRINT LOC(1); SEEK(1)\n"
    +        "DIM s AS STRING\ns = SPACE$(2)\n"
    +        "GET #1, 2, s\n"
    +        "PRINT s; LOC(1)\n"
    +        "CLOSE #1\nKILL \"test_suite_loc.tmp\"\n",
    +        "0 \n5 6 \nBC3 \n");
    +
    +    TEST_RUNTIME_ERROR("no-form-runtime-msgbox",
    +        "MsgBox \"hi\"\n",
    +        "No form runtime");
    +
    +    TEST_EQ("no-form-runtime-err-1000",
    +        "ON ERROR GOTO H\n"
    +        "DIM f AS LONG\n"
    +        "SET f = CreateForm(\"F\", 10, 10)\n"
    +        "END\n"
    +        "H:\n"
    +        "PRINT ERR\n"
    +        "END\n",
    +        "1000 \n");
    +
    +    TEST_EQ("fixed-string-field",
    +        "TYPE FixT\n  s AS STRING * 4\n  v AS STRING\nEND TYPE\n"
    +        "DIM t AS FixT\n"
    +        "PRINT LEN(t.s); \"[\"; t.s; \"]\"; LEN(t.v)\n"
    +        "t.s = \"abcdefg\"\nPRINT t.s\n"
    +        "DIM u AS FixT\nu = t\nu.s = \"z\"\nPRINT t.s; \"|\"; u.s; \"|\"\n",
    +        "4 [    ]0 \nabcd\nabcd|z   |\n");
    +
    +    // --- Statement/file/error battery (coverage) ---
    +    setenv("BASTESTVAR", "battery", 1);
    +
    +    TEST_EQ("error-statement",
    +        "ON ERROR GOTO H\nERROR 53\nPRINT \"no\"\nEND\nH:\nPRINT ERR\nEND\n",
    +        "53 \n");
    +    TEST_RUNTIME_ERROR("error-unhandled", "ERROR 91\n", "91");
    +    TEST_EQ("shell-statement", "SHELL \"true\"\nPRINT \"sh\"\n", "sh\n");
    +    TEST_EQ("sleep-zero", "SLEEP 0\nPRINT \"z\"\n", "z\n");
    +    TEST_EQ("environ-set", "PRINT ENVIRON$(\"BASTESTVAR\")\n", "battery\n");
    +    TEST_EQ("environ-missing", "PRINT \"[\" & ENVIRON$(\"BAS_NO_SUCH_VAR\") & \"]\"\n", "[]\n");
    +    TEST_RUNTIME_ERROR("string-negative", "PRINT STRING$(-1, 65)\n", "");
    +    TEST_RUNTIME_ERROR("mid-bad-start", "PRINT MID$(\"abc\", 0)\n", "Illegal");
    +    TEST_RUNTIME_ERROR("cint-overflow", "PRINT CINT(40000)\n", "Overflow");
    +    TEST_EQ("rnd-randomize", "RANDOMIZE 7\nDIM r AS DOUBLE\nr = RND\nPRINT r >= 0 AND r < 1\n", "-1 \n");
    +
    +    // WRITE # quoting and separators.
    +    TEST_EQ("write-hash",
    +        "OPEN \"ts_write.tmp\" FOR OUTPUT AS #1\n"
    +        "WRITE #1, 1, \"a\", 2.5\n"
    +        "CLOSE #1\n"
    +        "OPEN \"ts_write.tmp\" FOR INPUT AS #1\n"
    +        "DIM s AS STRING\n"
    +        "LINE INPUT #1, s\nPRINT s\n"
    +        "CLOSE #1\nKILL \"ts_write.tmp\"\n",
    +        "1,\"a\",2.5\n");
    +
    +    // Filesystem error routing.
    +    TEST_EQ("name-exists",
    +        "OPEN \"ts_a.tmp\" FOR OUTPUT AS #1\nCLOSE #1\n"
    +        "ON ERROR GOTO H\nNAME \"ts_a.tmp\" AS \"/no/dir/ts_b.tmp\"\nEND\nH:\nPRINT ERR\nKILL \"ts_a.tmp\"\nEND\n",
    +        "58 \n");
    +    TEST_EQ("filecopy-missing",
    +        "ON ERROR GOTO H\nFILECOPY \"ts_none.tmp\", \"ts_c.tmp\"\nEND\nH:\nPRINT ERR\nEND\n",
    +        "53 \n");
    +    TEST_EQ("rmdir-missing",
    +        "ON ERROR GOTO H\nRMDIR \"ts_no_dir\"\nEND\nH:\nPRINT ERR\nEND\n",
    +        "75 \n");
    +    TEST_EQ("attrs-roundtrip",
    +        "OPEN \"ts_at.tmp\" FOR OUTPUT AS #1\nCLOSE #1\n"
    +        "DIM a AS INTEGER\na = GETATTR(\"ts_at.tmp\")\nSETATTR \"ts_at.tmp\", a\nPRINT a >= 0\nKILL \"ts_at.tmp\"\n",
    +        "True \n");
    +    TEST_EQ("dir-with-path",
    +        "OPEN \"ts_d1.tmp\" FOR OUTPUT AS #1\nCLOSE #1\n"
    +        "DIM f AS STRING\nf = DIR$(\"./ts_d1.*\")\nPRINT f\nPRINT \"[\" & DIR$() & \"]\"\nKILL \"ts_d1.tmp\"\n",
    +        "ts_d1.tmp\n[]\n");
    +    TEST_EQ("seek-fn-loc-seq",
    +        "OPEN \"ts_sk.tmp\" FOR OUTPUT AS #1\n"
    +        "PRINT #1, \"hello\"\n"
    +        "PRINT SEEK(1); LOC(1)\n"
    +        "CLOSE #1\nKILL \"ts_sk.tmp\"\n",
    +        "7 0 \n");
    +
    +    // Channel exhaustion: 15 usable channels, then FREEFILE errors.
    +    {
    +        char    manySrc[4096];
    +        int32_t manyPos = 0;
    +
    +        for (int32_t ch = 1; ch <= 15; ch++) {
    +            manyPos += snprintf(manySrc + manyPos, sizeof(manySrc) - manyPos, "OPEN \"ts_ch%d.tmp\" FOR OUTPUT AS #%d\n", (int32_t)ch, (int32_t)ch);
    +        }
    +
    +        manyPos += snprintf(manySrc + manyPos, sizeof(manySrc) - manyPos, "ON ERROR GOTO H\nDIM n AS INTEGER\nn = FREEFILE\nPRINT \"free\"; n\nEND\nH:\nPRINT ERR\n");
    +
    +        for (int32_t ch = 1; ch <= 15; ch++) {
    +            manyPos += snprintf(manySrc + manyPos, sizeof(manySrc) - manyPos, "CLOSE #%d\nKILL \"ts_ch%d.tmp\"\n", (int32_t)ch, (int32_t)ch);
    +        }
    +
    +        manyPos += snprintf(manySrc + manyPos, sizeof(manySrc) - manyPos, "END\n");
    +        TEST_EQ("freefile-exhausted", manySrc, "67 \n");
    +    }
    +
    +    // LINE INPUT: CRLF endings and a line longer than the initial buffer.
    +    TEST_EQ("line-input-crlf",
    +        "OPEN \"ts_cr.tmp\" FOR OUTPUT AS #1\n"
    +        "PRINT #1, \"one\" & CHR$(13)\n"
    +        "PRINT #1, STRING$(300, 65)\n"
    +        "CLOSE #1\n"
    +        "OPEN \"ts_cr.tmp\" FOR INPUT AS #1\n"
    +        "DIM s AS STRING\n"
    +        "LINE INPUT #1, s\nPRINT LEN(s); s\n"
    +        "LINE INPUT #1, s\nPRINT LEN(s)\n"
    +        "CLOSE #1\nKILL \"ts_cr.tmp\"\n",
    +        "3 one\n300 \n");
    +
    +    // PRINT USING: literal-only formats, escapes, string fields, wrap,
    +    // scientific fields, and the string/number mismatch error.
    +    TEST_EQ("using-literal-only", "PRINT USING \"abc\"; 5\n", "abcabc5\n");
    +    TEST_EQ("using-escape", "PRINT USING \"_## \"; 7\n", "#7 \n");
    +    TEST_EQ("using-str-fields", "PRINT USING \"!& \"; \"xy\"; \"zz\"\n", "xzz \n");
    +    TEST_EQ("using-wrap", "PRINT USING \"#-\"; 1; 2\n", "1 2 \n");
    +    TEST_EQ("using-sci", "PRINT USING \"##.##^^^^\"; 1234.5\n", " 1.23E+03\n");
    +    TEST_RUNTIME_ERROR("using-type-mismatch", "PRINT USING \"###\"; \"str\"\n", "");
    +
    +    // FOR loop variables of each numeric type, including values past
    +    // the int32 range, and a GOTO back into a live FOR.
    +    TEST_EQ("for-double-var-big",
    +        "DIM d AS DOUBLE\nDIM n AS INTEGER\nFOR d = 1e10 TO 1e10 + 2\n  n = n + 1\nNEXT d\nPRINT n\n",
    +        "3 \n");
    +    TEST_EQ("for-single-var",
    +        "DIM s AS SINGLE\nDIM n AS INTEGER\nFOR s = 0.5 TO 2.5\n  n = n + 1\nNEXT s\nPRINT n\n",
    +        "3 \n");
    +    TEST_EQ("for-long-var",
    +        "DIM l AS LONG\nDIM n AS INTEGER\nFOR l = 70000 TO 70002\n  n = n + 1\nNEXT l\nPRINT n\n",
    +        "3 \n");
    +    TEST_EQ("goto-back-into-for",
    +        "DIM i AS INTEGER\nDIM n AS INTEGER\nR:\nFOR i = 1 TO 2\n  n = n + 1\n  IF n = 3 THEN GOTO D\n  IF i = 2 THEN GOTO R\nNEXT i\nD:\nPRINT n\n",
    +        "3 \n");
    +
    +    // RGB packing and component extraction.
    +    TEST_EQ("rgb-components",
    +        "DIM c AS LONG\nc = RGB(255, 128, 3)\nPRINT GETRED(c); GETGREEN(c); GETBLUE(c)\n",
    +        "255 128 3 \n");
    +
    +    // Integer operator edges: MOD by zero, add overflow widening to
    +    // LONG, LONG comparisons, and integer-divide operand overflow.
    +    TEST_EQ("mod-by-zero",
    +        "ON ERROR GOTO H\nDIM z AS INTEGER\nz = 0\nPRINT 5 MOD z\nEND\nH:\nPRINT ERR\nEND\n",
    +        "11 \n");
    +    TEST_EQ("add-widens-to-long", "PRINT 32000 + 32000\n", "64000 \n");
    +    TEST_EQ("long-compare", "PRINT 100000 < 200000; 100000 = 100000\n", "True True \n");
    +    TEST_EQ("idiv-operand-overflow",
    +        "ON ERROR GOTO H\nDIM d AS DOUBLE\nd = 3e9\nPRINT d \\ 1\nEND\nH:\nPRINT ERR\nEND\n",
    +        "6 \n");
    +    TEST_EQ("instr-three-arg", "PRINT INSTR(2, \"abab\", \"ab\")\n", "3 \n");
    +    TEST_EQ("string-first-char", "PRINT STRING$(3, \"AB\")\n", "AAA\n");
    +    TEST_EQ("using-empty-format", "PRINT USING \"\"; 7\n", "7\n");
    +    TEST_EQ("dir-trailing-slash",
    +        "DIM f AS STRING\nf = DIR$(\"./\")\nPRINT LEN(f) >= 0\n",
    +        "True \n");
    +    TEST_EQ("chdir-missing",
    +        "ON ERROR GOTO H\nCHDIR \"ts_no_dir_x\"\nEND\nH:\nPRINT ERR\nEND\n",
    +        "76 \n");
    +    TEST_EQ("mkdir-existing",
    +        "MKDIR \"ts_mk_dir\"\nON ERROR GOTO H\nMKDIR \"ts_mk_dir\"\nEND\nH:\nPRINT ERR\nRMDIR \"ts_mk_dir\"\nEND\n",
    +        "75 \n");
    +
    +    TEST_EQ("print-spc-tab-exprs",
    +        "DIM n AS INTEGER\nn = 2\nPRINT \"a\"; SPC(n + 1); \"b\"; TAB(n * 6); \"c\"\n",
    +        "a   b      c\n");
    +    TEST_EQ("next-outer-var-trims-inner",
    +        "DIM i AS INTEGER\nDIM j AS INTEGER\nDIM n AS INTEGER\n"
    +        "FOR i = 1 TO 2\n"
    +        "  FOR j = 1 TO 9\n"
    +        "    n = n + 1\n"
    +        "    GOTO L\n"
    +        "  NEXT j\n"
    +        "L:\n"
    +        "NEXT i\n"
    +        "PRINT n\n",
    +        "2 \n");
    +    TEST_EQ("for-step-widens-int-slot",
    +        "DIM i AS INTEGER\nDIM n AS INTEGER\nFOR i = 1 TO 100000 STEP 40000\n  n = n + 1\nNEXT i\nPRINT n\n",
    +        "3 \n");
    +    TEST_EQ("for-step-widens-past-long",
    +        "DIM l AS LONG\nDIM n AS INTEGER\nFOR l = 1 TO 5e9 STEP 3e9\n  n = n + 1\nNEXT l\nPRINT n\n",
    +        "2 \n");
    +    TEST_EQ("idiv-by-zero-var",
    +        "ON ERROR GOTO H\nDIM z AS INTEGER\nz = 0\nPRINT 5 \\ z\nEND\nH:\nPRINT ERR\nEND\n",
    +        "11 \n");
    +    TEST_EQ("ini-read-skips-noise",
    +        "OPEN \"ts_ini3.tmp\" FOR OUTPUT AS #1\n"
    +        "PRINT #1, \"; leading comment\"\n"
    +        "PRINT #1, \"\"\n"
    +        "PRINT #1, \"[Skip]\"\n"
    +        "PRINT #1, \"k = wrong\"\n"
    +        "PRINT #1, \"[Hit]\"\n"
    +        "PRINT #1, \"k = right\"\n"
    +        "CLOSE #1\n"
    +        "PRINT INIREAD(\"ts_ini3.tmp\", \"Hit\", \"k\", \"?\")\n"
    +        "KILL \"ts_ini3.tmp\"\n",
    +        "right\n");
    +
    +    // RESUME NEXT over a statement that IS a GOSUB call: the scanner
    +    // steps across the push/jump pattern.
    +    TEST_EQ("resume-over-gosub",
    +        "ON ERROR GOTO H\nGOSUB S\nPRINT \"after\"\nEND\nS:\nERROR 5\nRETURN\nH:\nRESUME NEXT\n",
    +        "after\n");
    +
    +    // UDT arrays through ERASE and REDIM.
    +    TEST_EQ("erase-udt-array",
    +        "TYPE PtT\n  x AS INTEGER\n  s AS STRING\nEND TYPE\n"
    +        "DIM a(2) AS PtT\na(1).x = 3\na(1).s = \"v\"\nERASE a\nPRINT a(1).x; \"[\" & a(1).s & \"]\"\n",
    +        "0 []\n");
    +    TEST_EQ("redim-udt-array",
    +        "TYPE PtT\n  x AS INTEGER\nEND TYPE\n"
    +        "DIM a() AS PtT\nREDIM a(2)\na(1).x = 4\nREDIM a(4)\nPRINT UBOUND(a); a(1).x\n",
    +        "4 0 \n");
    +
    +    // File channels: double open closes the old handle, everything on an
    +    // unopened channel raises, INPUT$ rejects a negative count, and a
    +    // channel number past the table is refused.
    +    TEST_EQ("open-twice-same-channel",
    +        "OPEN \"ts_o1.tmp\" FOR OUTPUT AS #1\n"
    +        "OPEN \"ts_o2.tmp\" FOR OUTPUT AS #1\n"
    +        "PRINT #1, \"x\"\nCLOSE #1\n"
    +        "PRINT FILELEN(\"ts_o2.tmp\") > 0\n"
    +        "KILL \"ts_o1.tmp\"\nKILL \"ts_o2.tmp\"\n",
    +        "True \n");
    +    TEST_EQ("unopened-channel-battery",
    +        "ON ERROR GOTO H\nDIM n AS INTEGER\nDIM s AS STRING\n"
    +        "PRINT #3, \"x\"\nR1:\n"
    +        "WRITE #3, 1\nR2:\n"
    +        "LINE INPUT #3, s\nR3:\n"
    +        "n = LOF(3)\nR4:\n"
    +        "SEEK #3, 1\nR5:\n"
    +        "PRINT \"done\"; ERR\nEND\n"
    +        "H:\nPRINT ERR\nRESUME NEXT\n",
    +        "52 \n52 \n52 \n52 \n52 \ndone0 \n");
    +    TEST_EQ("input-fn-negative",
    +        "OPEN \"ts_in.tmp\" FOR OUTPUT AS #1\nPRINT #1, \"abc\"\nCLOSE #1\n"
    +        "OPEN \"ts_in.tmp\" FOR INPUT AS #1\n"
    +        "ON ERROR GOTO H\nDIM s AS STRING\ns = INPUT$(-1, 1)\nEND\nH:\nPRINT ERR\nCLOSE #1\nKILL \"ts_in.tmp\"\nEND\n",
    +        "5 \n");
    +    TEST_EQ("channel-out-of-range",
    +        "ON ERROR GOTO H\nOPEN \"ts_c.tmp\" FOR OUTPUT AS #16\nEND\nH:\nPRINT ERR\nEND\n",
    +        "52 \n");
    +
    +    // LINE INPUT / INPUT # stream edges: bare CR line endings, a line
    +    // longer than the reader's initial buffer, quoted INPUT fields.
    +    TEST_EQ("line-input-bare-cr",
    +        "OPEN \"ts_cr2.tmp\" FOR OUTPUT AS #1\n"
    +        "PRINT #1, \"a\" & CHR$(13) & \"b\";\n"
    +        "CLOSE #1\n"
    +        "OPEN \"ts_cr2.tmp\" FOR INPUT AS #1\n"
    +        "DIM s AS STRING\n"
    +        "LINE INPUT #1, s\nPRINT LEN(s)\n"
    +        "CLOSE #1\nKILL \"ts_cr2.tmp\"\n",
    +        "3 \n");
    +    TEST_EQ("line-input-very-long",
    +        "OPEN \"ts_lg.tmp\" FOR OUTPUT AS #1\n"
    +        "PRINT #1, STRING$(2000, 66)\n"
    +        "CLOSE #1\n"
    +        "OPEN \"ts_lg.tmp\" FOR INPUT AS #1\n"
    +        "DIM s AS STRING\n"
    +        "LINE INPUT #1, s\nPRINT LEN(s)\n"
    +        "CLOSE #1\nKILL \"ts_lg.tmp\"\n",
    +        "2000 \n");
    +    TEST_EQ("input-hash-quoted",
    +        "OPEN \"ts_q.tmp\" FOR OUTPUT AS #1\n"
    +        "PRINT #1, CHR$(34) & \"with, comma\" & CHR$(34) & \", 7\"\n"
    +        "CLOSE #1\n"
    +        "OPEN \"ts_q.tmp\" FOR INPUT AS #1\n"
    +        "DIM s AS STRING\nDIM n AS INTEGER\n"
    +        "INPUT #1, s, n\nPRINT \"[\" & s & \"]\"; n\n"
    +        "CLOSE #1\nKILL \"ts_q.tmp\"\n",
    +        "[with, comma]7 \n");
    +
    +    // INI edges: CRLF line endings, a file with no trailing newline, and
    +    // inserting a new key while another section follows.
    +    TEST_EQ("ini-crlf-insert",
    +        "OPEN \"ts_ini2.tmp\" FOR OUTPUT AS #1\n"
    +        "PRINT #1, \"[One]\" & CHR$(13)\n"
    +        "PRINT #1, \"a = 1\" & CHR$(13)\n"
    +        "PRINT #1, \"[Two]\" & CHR$(13)\n"
    +        "PRINT #1, \"b = 2\";\n"
    +        "CLOSE #1\n"
    +        "INIWRITE \"ts_ini2.tmp\", \"One\", \"c\", \"3\"\n"
    +        "PRINT INIREAD(\"ts_ini2.tmp\", \"One\", \"c\", \"?\")\n"
    +        "PRINT INIREAD(\"ts_ini2.tmp\", \"One\", \"a\", \"?\")\n"
    +        "PRINT INIREAD(\"ts_ini2.tmp\", \"Two\", \"b\", \"?\")\n"
    +        "KILL \"ts_ini2.tmp\"\n",
    +        "3\n1\n2\n");
    +
    +    // UDT arrays, fixed strings through PUT/GET, ERASE modes and REDIM.
    +    TEST_EQ("udt-array-field",
    +        "TYPE PtT\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
    +        "DIM a(3) AS PtT\n"
    +        "a(1).x = 7\na(1).y = a(1).x + 1\n"
    +        "PRINT a(1).x; a(1).y\n",
    +        "7 8 \n");
    +    TEST_EQ("put-get-fixed-udt",
    +        "TYPE RecT\n  tag AS STRING * 6\n  n AS LONG\nEND TYPE\n"
    +        "DIM r AS RecT\nDIM s AS RecT\n"
    +        "r.tag = \"ab\"\nr.n = 9\n"
    +        "OPEN \"ts_udt.tmp\" FOR RANDOM AS #1 LEN = 32\n"
    +        "PUT #1, 1, r\nGET #1, 1, s\n"
    +        "CLOSE #1\nKILL \"ts_udt.tmp\"\n"
    +        "PRINT \"[\" & s.tag & \"]\"; s.n\n",
    +        "[ab    ]9 \n");
    +    TEST_EQ("erase-zero-keeps-bounds",
    +        "DIM a(2) AS INTEGER\na(1) = 5\nERASE a\nPRINT a(1); UBOUND(a)\n",
    +        "0 2 \n");
    +    TEST_RUNTIME_ERROR("ubound-bad-dim", "DIM a(2) AS INTEGER\nPRINT UBOUND(a, 9)\n", "dimension");
    +    TEST_EQ("redim-preserve",
    +        "DIM a() AS INTEGER\nREDIM a(2)\na(2) = 4\nREDIM PRESERVE a(5)\nPRINT a(2); UBOUND(a)\n",
    +        "4 5 \n");
    +
    +    // INI write: create, rewrite an existing key, append a new section,
    +    // and a file with no trailing newline.
    +    TEST_EQ("ini-write-battery",
    +        "OPEN \"ts_ini.tmp\" FOR OUTPUT AS #1\n"
    +        "PRINT #1, \"[One]\"\n"
    +        "PRINT #1, \"k = old\"\n"
    +        "CLOSE #1\n"
    +        "INIWRITE \"ts_ini.tmp\", \"One\", \"k\", \"new\"\n"
    +        "INIWRITE \"ts_ini.tmp\", \"One\", \"extra\", \"e1\"\n"
    +        "INIWRITE \"ts_ini.tmp\", \"Two\", \"t\", \"t1\"\n"
    +        "PRINT INIREAD(\"ts_ini.tmp\", \"One\", \"k\", \"?\")\n"
    +        "PRINT INIREAD(\"ts_ini.tmp\", \"One\", \"extra\", \"?\")\n"
    +        "PRINT INIREAD(\"ts_ini.tmp\", \"Two\", \"t\", \"?\")\n"
    +        "PRINT INIREAD(\"ts_ini.tmp\", \"Two\", \"gone\", \"dflt\")\n"
    +        "KILL \"ts_ini.tmp\"\n",
    +        "new\ne1\nt1\ndflt\n");
    +
    +    // RESUME NEXT walks the erroring statement's operands: cover every
    +    // operand width the scanner distinguishes.
    +    TEST_EQ("resume-walk-f64",
    +        "ON ERROR GOTO H\nDIM x AS DOUBLE\nDIM z AS INTEGER\nz = 0\nx = 1.5 + (1 \\ z)\nPRINT \"w1\"\nEND\nH:\nRESUME NEXT\n",
    +        "w1\n");
    +    TEST_EQ("resume-walk-i32",
    +        "ON ERROR GOTO H\nDIM x AS LONG\nDIM z AS INTEGER\nz = 0\nx = 100000 + (1 \\ z)\nPRINT \"w2\"\nEND\nH:\nRESUME NEXT\n",
    +        "w2\n");
    +    TEST_EQ("resume-walk-array",
    +        "ON ERROR GOTO H\nDIM a(3) AS INTEGER\nDIM z AS INTEGER\nz = 0\na(1) = 1 \\ z\nPRINT \"w3\"\nEND\nH:\nRESUME NEXT\n",
    +        "w3\n");
    +    TEST_EQ("resume-walk-udt-array",
    +        "TYPE PtT\n  x AS INTEGER\nEND TYPE\n"
    +        "ON ERROR GOTO H\nDIM a(3) AS PtT\nDIM z AS INTEGER\nz = 0\na(1).x = 1 \\ z\nPRINT \"w4\"\nEND\nH:\nRESUME NEXT\n",
    +        "w4\n");
    +    TEST_EQ("resume-walk-gosub",
    +        "ON ERROR GOTO H\nDIM z AS INTEGER\nDIM x AS INTEGER\nz = 0\nx = 1 \\ z : GOSUB S\nPRINT \"w5\"\nEND\nS:\nPRINT \"sub\"\nRETURN\nH:\nRESUME NEXT\n",
    +        "sub\nw5\n");
    +
    +
         // Debugger pause vs ON ERROR, and the release payload path.
         testBreakpointNotTrapped("breakpoint-not-trapped");
         testReleaseRoundTrip("release-round-trip");
     
    +    // Hand-assembled p-code, lexer token stream and raw codegen output.
    +    testHandAssembled();
    +    testHandBattery();
    +    testOpcodeGuards();
    +    testBasBuild();
    +
    +    // Coverage battery: debugger, externs, serialization, values API.
    +    testDebuggerApi();
    +    testExternCalls();
    +    testDebugInfoRoundTrip("debug-info-round-trip");
    +    testInputCallback("input-callback");
    +    testLoadModuleRefusal("load-module-refusal");
    +    testSerializeCorrupt("serialize-round-trip");
    +    testValuesApi();
    +
    +    {
    +        static const ExpectedTokenT helloTokens[] = {
    +            { TOK_PRINT,      "PRINT"         },
    +            { TOK_STRING_LIT, "Hello, World!" },
    +            { TOK_NEWLINE,    "\n"            },
    +            { TOK_EOF,        ""              },
    +        };
    +        static const uint8_t helloCode[] = {
    +            OP_LINE, 0x01, 0x00,
    +            OP_PUSH_STR, 0x00, 0x00,
    +            OP_PRINT,
    +            OP_PRINT_NL,
    +            OP_HALT,
    +        };
    +
    +        testLexerTokens("lex-print-string", "PRINT \"Hello, World!\"\n", helloTokens, (int32_t)(sizeof(helloTokens) / sizeof(helloTokens[0])));
    +        testBytecode("bytecode-print-string", "PRINT \"Hello, World!\"\n", helloCode, (int32_t)sizeof(helloCode));
    +    }
    +
         printf("\n--------------------------\n");
         printf("PASS: %d   FAIL: %d\n", (int)sPassCount, (int)sFailCount);
     
    diff --git a/src/apps/kpunch/dvxbasic/test_vm.c b/src/apps/kpunch/dvxbasic/test_vm.c
    deleted file mode 100644
    index 19f0e60..0000000
    --- a/src/apps/kpunch/dvxbasic/test_vm.c
    +++ /dev/null
    @@ -1,242 +0,0 @@
    -// The MIT License (MIT)
    -//
    -// Copyright (C) 2026 Scott Duensing
    -//
    -// Permission is hereby granted, free of charge, to any person obtaining a copy
    -// of this software and associated documentation files (the "Software"), to
    -// deal in the Software without restriction, including without limitation the
    -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
    -// sell copies of the Software, and to permit persons to whom the Software is
    -// furnished to do so, subject to the following conditions:
    -//
    -// The above copyright notice and this permission notice shall be included in
    -// all copies or substantial portions of the Software.
    -//
    -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
    -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    -// IN THE SOFTWARE.
    -
    -// test_vm.c -- Quick test for the DVX BASIC VM
    -//
    -// Hand-assembles a small p-code program and executes it.
    -// Tests: PRINT "Hello, World!", arithmetic, FOR loop, string ops.
    -//
    -// Build: make -C dvxbasic tests
    -
    -#include "compiler/opcodes.h"
    -#include "runtime/vm.h"
    -#include "runtime/values.h"
    -
    -#include 
    -#include 
    -
    -// ============================================================
    -// Helper: emit bytes into a code buffer
    -// ============================================================
    -
    -static uint8_t sCode[4096];
    -static int32_t sCodeLen = 0;
    -
    -// Function prototypes
    -static void emit16(int16_t v);
    -static void emit8(uint8_t b);
    -static void emitU16(uint16_t v);
    -static void test1(void);
    -static void test2(void);
    -static void test3(void);
    -static void test4(void);
    -
    -
    -static void emit16(int16_t v) {
    -    memcpy(&sCode[sCodeLen], &v, 2);
    -    sCodeLen += 2;
    -}
    -
    -
    -static void emit8(uint8_t b) {
    -    sCode[sCodeLen++] = b;
    -}
    -
    -
    -static void emitU16(uint16_t v) {
    -    emit16((int16_t)v);
    -}
    -
    -
    -static void test1(void) {
    -    printf("--- Test 1: PRINT \"Hello, World!\" ---\n");
    -
    -    sCodeLen = 0;
    -
    -    // String constant pool
    -    BasStringT *consts[1];
    -    consts[0] = basStringNew("Hello, World!", 13);
    -
    -    // Code: PUSH_STR 0; PRINT; PRINT_NL; HALT
    -    emit8(OP_PUSH_STR);
    -    emitU16(0);
    -    emit8(OP_PRINT);
    -    emit8(OP_PRINT_NL);
    -    emit8(OP_HALT);
    -
    -    BasModuleT module;
    -    memset(&module, 0, sizeof(module));
    -    module.code       = sCode;
    -    module.codeLen    = sCodeLen;
    -    module.constants  = consts;
    -    module.constCount = 1;
    -    module.entryPoint = 0;
    -
    -    BasVmT *vm = basVmCreate();
    -    basVmLoadModule(vm, &module);
    -    BasVmResultE result = basVmRun(vm);
    -    printf("Result: %d (expected %d = HALTED)\n\n", result, BAS_VM_HALTED);
    -    basVmDestroy(vm);
    -    basStringUnref(consts[0]);
    -}
    -
    -
    -static void test2(void) {
    -    printf("--- Test 2: PRINT 2 + 3 * 4 (expect 14) ---\n");
    -
    -    sCodeLen = 0;
    -
    -    // Code: PUSH 3; PUSH 4; MUL; PUSH 2; ADD; PRINT; PRINT_NL; HALT
    -    emit8(OP_PUSH_INT16);
    -    emit16(3);
    -    emit8(OP_PUSH_INT16);
    -    emit16(4);
    -    emit8(OP_MUL_INT);
    -    emit8(OP_PUSH_INT16);
    -    emit16(2);
    -    emit8(OP_ADD_INT);
    -    emit8(OP_PRINT);
    -    emit8(OP_PRINT_NL);
    -    emit8(OP_HALT);
    -
    -    BasModuleT module;
    -    memset(&module, 0, sizeof(module));
    -    module.code       = sCode;
    -    module.codeLen    = sCodeLen;
    -    module.entryPoint = 0;
    -
    -    BasVmT *vm = basVmCreate();
    -    basVmLoadModule(vm, &module);
    -    basVmRun(vm);
    -    basVmDestroy(vm);
    -    printf("\n");
    -}
    -
    -
    -static void test3(void) {
    -    printf("--- Test 3: PRINT \"Hello\" & \" \" & \"BASIC\" ---\n");
    -
    -    sCodeLen = 0;
    -
    -    BasStringT *consts[3];
    -    consts[0] = basStringNew("Hello", 5);
    -    consts[1] = basStringNew(" ", 1);
    -    consts[2] = basStringNew("BASIC", 5);
    -
    -    // Code: PUSH consts[0]; PUSH consts[1]; CONCAT; PUSH consts[2]; CONCAT; PRINT; PRINT_NL; HALT
    -    emit8(OP_PUSH_STR); emitU16(0);
    -    emit8(OP_PUSH_STR); emitU16(1);
    -    emit8(OP_STR_CONCAT);
    -    emit8(OP_PUSH_STR); emitU16(2);
    -    emit8(OP_STR_CONCAT);
    -    emit8(OP_PRINT);
    -    emit8(OP_PRINT_NL);
    -    emit8(OP_HALT);
    -
    -    BasModuleT module;
    -    memset(&module, 0, sizeof(module));
    -    module.code       = sCode;
    -    module.codeLen    = sCodeLen;
    -    module.constants  = consts;
    -    module.constCount = 3;
    -    module.entryPoint = 0;
    -
    -    BasVmT *vm = basVmCreate();
    -    basVmLoadModule(vm, &module);
    -    basVmRun(vm);
    -    basVmDestroy(vm);
    -    printf("\n");
    -
    -    basStringUnref(consts[0]);
    -    basStringUnref(consts[1]);
    -    basStringUnref(consts[2]);
    -}
    -
    -
    -static void test4(void) {
    -    printf("--- Test 4: FOR i = 1 TO 5: PRINT i: NEXT ---\n");
    -
    -    sCodeLen = 0;
    -
    -    // We need a call frame with at least 1 local (the loop variable)
    -    // For module-level code, we use callStack[0] as implicit frame
    -
    -    // Setup: store initial value in local 0
    -    // PUSH 1; STORE_LOCAL 0  -- i = 1
    -    emit8(OP_PUSH_INT16); emit16(1);
    -    emit8(OP_STORE_LOCAL); emitU16(0);
    -
    -    // Push limit and step for FOR_INIT
    -    // PUSH 5 (limit); PUSH 1 (step)
    -    emit8(OP_PUSH_INT16); emit16(5);
    -    emit8(OP_PUSH_INT16); emit16(1);
    -    emit8(OP_FOR_INIT); emitU16(0); emit8(1); emit16(0); // scope=local, skipOffset patched below
    -
    -    // Loop body start (record PC for FOR_NEXT offset)
    -    int32_t loopBody = sCodeLen;
    -
    -    // LOAD_LOCAL 0; PRINT; PRINT " "
    -    emit8(OP_LOAD_LOCAL); emitU16(0);
    -    emit8(OP_PRINT);
    -
    -    // FOR_NEXT: increment i, test, jump back
    -    emit8(OP_FOR_NEXT);
    -    emitU16(0);  // local index
    -    emit8(SCOPE_LOCAL);
    -    int16_t offset = (int16_t)(loopBody - (sCodeLen + 2));
    -    emit16(offset);
    -
    -    // After loop
    -    emit8(OP_PRINT_NL);
    -    emit8(OP_HALT);
    -
    -    BasModuleT module;
    -    memset(&module, 0, sizeof(module));
    -    module.code       = sCode;
    -    module.codeLen    = sCodeLen;
    -    module.entryPoint = 0;
    -
    -    BasVmT *vm = basVmCreate();
    -
    -    // Initialize the implicit main frame with 1 local
    -    vm->callStack[0].localCount = 1;
    -    vm->callDepth = 1;
    -
    -    basVmLoadModule(vm, &module);
    -    basVmRun(vm);
    -    basVmDestroy(vm);
    -    printf("\n");
    -}
    -
    -
    -int main(void) {
    -    printf("DVX BASIC VM Tests\n");
    -    printf("==================\n\n");
    -
    -    test1();
    -    test2();
    -    test3();
    -    test4();
    -
    -    printf("All tests complete.\n");
    -    return 0;
    -}
    diff --git a/src/apps/kpunch/dvxbasic/ts_b.tmp b/src/apps/kpunch/dvxbasic/ts_b.tmp
    new file mode 100644
    index 0000000..e69de29
    diff --git a/src/apps/kpunch/dvxhelp/dvxhelp.c b/src/apps/kpunch/dvxhelp/dvxhelp.c
    index 74d2d81..0d71d0e 100644
    --- a/src/apps/kpunch/dvxhelp/dvxhelp.c
    +++ b/src/apps/kpunch/dvxhelp/dvxhelp.c
    @@ -166,7 +166,7 @@ static int32_t sHistoryPos   = -1;
     static int32_t sHistoryCount = 0;
     static int32_t sCurrentTopic = -1;
     
    -// Viewport wrap width — the width text should wrap at, derived from the
    +// Viewport wrap width -- the width text should wrap at, derived from the
     // ScrollPane's inner area. Updated when the window is first laid out and
     // on resize. Wrapping widgets use this instead of w->parent->w so that
     // wide tables/code don't inflate the wrap width.
    @@ -186,6 +186,7 @@ static int32_t sHelpListItemTypeId = -1;
     // ============================================================
     
     int32_t     appMain(DxeAppContextT *ctx);
    +void        appShutdown(void);
     static void buildContentWidgets(void);
     static void closeHelpFile(void);
     static int32_t countLines(const char *text);
    @@ -218,10 +219,10 @@ static void historyPush(int32_t topicIdx);
     static const char *hlpString(uint32_t offset);
     static bool isFirstChild(WidgetT *w);
     static int32_t maxLineWidth(const BitmapFontT *font, const char *text);
    -static bool nextSiblingHasOwnBorder(WidgetT *w);
     static void navigateBack(void);
     static void navigateForward(void);
     static void navigateToTopic(int32_t topicIdx);
    +static bool nextSiblingHasOwnBorder(WidgetT *w);
     static void onClose(WindowT *win);
     static void onIndex(WidgetT *w);
     static void onMenu(WindowT *win, int32_t menuId);
    @@ -325,6 +326,13 @@ AppDescriptorT appDescriptor = {
     };
     
     
    +// Shell force-kill/reap hook: release the help file data even when onClose
    +// never ran (the shell destroys the window itself).
    +void appShutdown(void) {
    +    closeHelpFile();
    +}
    +
    +
     // Re-wrap all text content widgets at the current content box width.
     
     static void buildContentWidgets(void) {
    @@ -523,7 +531,7 @@ static void displayRecord(const HlpRecordHdrT *hdr, const char *payload) {
                     const HlpImageRefT *imgRef = (const HlpImageRefT *)payload;
                     uint32_t absOffset = sHeader.imagePoolOffset + imgRef->imageOffset;
     
    -                // Save file position — buildContentWidgets reads records
    +                // Save file position -- buildContentWidgets reads records
                     // sequentially and we must not disturb its position.
                     long savedPos = ftell(sHlpFile);
     
    @@ -650,7 +658,7 @@ static void displayRecord(const HlpRecordHdrT *hdr, const char *payload) {
     
     // Ensure a HelpText-style widget's wrapped text is up to date for the given pixel width.
     // Returns the wrapped text and updates lineCount. Uses cached result if width unchanged.
    -// Wrap text at the given pixel width. Caches the result — only re-wraps
    +// Wrap text at the given pixel width. Caches the result -- only re-wraps
     // when pixelW changes. Returns the wrapped string.
     static const char *doWrap(char **wrappedPtr, int32_t *wrapWidthPtr, int32_t *lineCountPtr, const char *text, int32_t pixelW, int32_t charW, int32_t padPixels) {
         int32_t cols = (pixelW - padPixels) / charW;
    diff --git a/src/apps/kpunch/progman/progman.c b/src/apps/kpunch/progman/progman.c
    index 13acc5a..d52baef 100644
    --- a/src/apps/kpunch/progman/progman.c
    +++ b/src/apps/kpunch/progman/progman.c
    @@ -133,10 +133,11 @@ static int32_t         sAppCount    = 0;
     // ============================================================
     
     int32_t     appMain(DxeAppContextT *ctx);
    -void        appShutdown(void);
     static int32_t appEntryCmpName(const void *a, const void *b);
    +void        appShutdown(void);
     static void buildPmWindow(void);
     static void desktopUpdate(void);
    +static void freeAppFiles(void);
     static void onAppButtonClick(WidgetT *w);
     static void onPmClose(WindowT *win);
     static void onPmMenu(WindowT *win, int32_t menuId);
    @@ -163,12 +164,6 @@ AppDescriptorT appDescriptor = {
     };
     
     
    -void appShutdown(void) {
    -    shellUnregisterDesktopUpdate(desktopUpdate);
    -    dvxQuit(sAc);
    -}
    -
    -
     // qsort comparator for AppEntryT -- case-insensitive on display name.
     static int32_t appEntryCmpName(const void *a, const void *b) {
         const AppEntryT *ea = (const AppEntryT *)a;
    @@ -177,6 +172,15 @@ static int32_t appEntryCmpName(const void *a, const void *b) {
     }
     
     
    +void appShutdown(void) {
    +    shellUnregisterDesktopUpdate(desktopUpdate);
    +    freeAppFiles();
    +    prefsClose(sPrefs);
    +    sPrefs = NULL;
    +    dvxQuit(sAc);
    +}
    +
    +
     // Build the main Program Manager window with app buttons, menus, and status bar.
     // Window is centered horizontally and placed in the upper quarter vertically
     // so spawned app windows don't hide behind it.
    @@ -313,6 +317,18 @@ static void desktopUpdate(void) {
     
     // Widget click handler for app grid buttons. userData was set to the
     // AppEntryT pointer during window construction, giving us the .app path.
    +// Release the scanned app list and its icon bitmaps.
    +static void freeAppFiles(void) {
    +    for (int32_t i = 0; i < sAppCount; i++) {
    +        free(sAppFiles[i].iconData);
    +    }
    +
    +    arrfree(sAppFiles);
    +    sAppFiles = NULL;
    +    sAppCount = 0;
    +}
    +
    +
     static void onAppButtonClick(WidgetT *w) {
         AppEntryT *entry = (AppEntryT *)w->userData;
     
    @@ -448,14 +464,7 @@ static void scanAppsDir(void) {
     
         sScanning = true;
     
    -    // Free icons from previous scan
    -    for (int32_t i = 0; i < sAppCount; i++) {
    -        free(sAppFiles[i].iconData);
    -    }
    -
    -    arrfree(sAppFiles);
    -    sAppFiles = NULL;
    -    sAppCount = 0;
    +    freeAppFiles();
         scanAppsDirRecurse("apps");
     
         if (sAppCount > 1) {
    @@ -607,7 +616,7 @@ int32_t appMain(DxeAppContextT *ctx) {
         sCtx     = ctx;
         sAc      = ctx->shellCtx;
     
    -    // Set help file for F1 — system help lives in progman's app directory
    +    // Set help file for F1 -- system help lives in progman's app directory
         snprintf(ctx->helpFile, sizeof(ctx->helpFile), "%s" DVX_PATH_SEP "%s", ctx->appDir, "dvxhelp.hlp");
     
         // Load saved preferences
    @@ -625,6 +634,5 @@ int32_t appMain(DxeAppContextT *ctx) {
         shellRegisterDesktopUpdate(desktopUpdate);
     
     
    -
         return 0;
     }
    diff --git a/src/libs/kpunch/dvxshell/shellApp.c b/src/libs/kpunch/dvxshell/shellApp.c
    index 33b8f4d..d481c65 100644
    --- a/src/libs/kpunch/dvxshell/shellApp.c
    +++ b/src/libs/kpunch/dvxshell/shellApp.c
    @@ -71,6 +71,7 @@ static void        makeTempPath(const char *origPath, int32_t id, char *out, int
     static void        releaseAppResources(ShellAppT *app);
     void               shellAppInit(void);
     int32_t            shellAppSlotCount(void);
    +void               shellAppTestReset(void);
     void               shellConfigPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize);
     int32_t            shellEnsureConfigDir(const DxeAppContextT *ctx);
     void               shellForceKillApp(AppContextT *ctx, ShellAppT *app);
    @@ -288,10 +289,12 @@ static void makeTempPath(const char *origPath, int32_t id, char *out, int32_t ou
             tmpDir = getenv("TMP");
         }
     
    +    // The fallback is anchored to the working directory: a bare file name
    +    // would make dlopen search the library path instead of opening it.
         if (tmpDir && tmpDir[0]) {
             snprintf(out, outSize, "%s" DVX_PATH_SEP "_dvx%02ld%s", tmpDir, (long)id, dot);
         } else {
    -        snprintf(out, outSize, "_dvx%02ld%s", (long)id, dot);
    +        snprintf(out, outSize, "." DVX_PATH_SEP "_dvx%02ld%s", (long)id, dot);
         }
     }
     
    @@ -327,6 +330,18 @@ int32_t shellAppSlotCount(void) {
     }
     
     
    +// Test seam: release every slot (the caller has already terminated the
    +// apps) so the next shellAppInit starts from an empty table.
    +void shellAppTestReset(void) {
    +    for (int32_t i = 0; i < arrlen(sApps); i++) {
    +        free(sApps[i]);
    +    }
    +
    +    arrfree(sApps);
    +    sApps = NULL;
    +}
    +
    +
     void shellConfigPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize) {
         snprintf(outPath, outSize, "%s" DVX_PATH_SEP "%s", ctx->configDir, filename);
     }
    @@ -393,7 +408,8 @@ ShellAppT *shellGetApp(int32_t appId) {
     // DXE3 is DJGPP's dynamic linking system  -- similar to dlopen/dlsym on Unix.
     // Each .app file is a DXE3 shared object that exports _appDescriptor and
     // _appMain (and optionally _appShutdown). The leading underscore is the
    -// COFF symbol convention; DJGPP's dlsym expects it.
    +// COFF symbol convention; DJGPP's dlsym expects it, ELF hosts use the
    +// bare name -- DVX_SYM() supplies whichever the platform needs.
     //
     // Multi-instance support: DXE3's dlopen returns the same handle for the
     // same path (reference-counted), so two loads of the same .dxe share all
    @@ -416,7 +432,7 @@ static int32_t shellLoadAppInternal(AppContextT *ctx, const char *path, const ch
     
         if (existing) {
             // Read multiInstance from the already-loaded descriptor
    -        AppDescriptorT *existDesc = (AppDescriptorT *)dlsym(existing->dxeHandle, "_appDescriptor");
    +        AppDescriptorT *existDesc = (AppDescriptorT *)dlsym(existing->dxeHandle, DVX_SYM("appDescriptor"));
     
             if (!existDesc || !existDesc->multiInstance) {
                 char msg[SHELL_MSG_MAX];
    @@ -456,7 +472,7 @@ static int32_t shellLoadAppInternal(AppContextT *ctx, const char *path, const ch
         dvxSetBusy(ctx, true);
     
         // Load the DXE
    -    void *handle = dlopen(loadPath, RTLD_GLOBAL);
    +    void *handle = dlopen(loadPath, RTLD_NOW | RTLD_GLOBAL);
     
         if (!handle) {
             char msg[SHELL_MSG_MAX];
    @@ -468,7 +484,7 @@ static int32_t shellLoadAppInternal(AppContextT *ctx, const char *path, const ch
         }
     
         // Look up required symbols
    -    AppDescriptorT *desc = (AppDescriptorT *)dlsym(handle, "_appDescriptor");
    +    AppDescriptorT *desc = (AppDescriptorT *)dlsym(handle, DVX_SYM("appDescriptor"));
     
         if (!desc) {
             char msg[SHELL_MSG_MAX];
    @@ -479,7 +495,7 @@ static int32_t shellLoadAppInternal(AppContextT *ctx, const char *path, const ch
             return shellLoadFail(handle, tempPath);
         }
     
    -    int32_t (*entry)(DxeAppContextT *) = (int32_t (*)(DxeAppContextT *))dlsym(handle, "_appMain");
    +    int32_t (*entry)(DxeAppContextT *) = (int32_t (*)(DxeAppContextT *))dlsym(handle, DVX_SYM("appMain"));
     
         if (!entry) {
             char msg[SHELL_MSG_MAX];
    @@ -489,7 +505,7 @@ static int32_t shellLoadAppInternal(AppContextT *ctx, const char *path, const ch
             return shellLoadFail(handle, tempPath);
         }
     
    -    void (*shutdown)(void) = (void (*)(void))dlsym(handle, "_appShutdown");
    +    void (*shutdown)(void) = (void (*)(void))dlsym(handle, DVX_SYM("appShutdown"));
     
         // Fill in the app slot
         ShellAppT *app = sApps[id];
    @@ -579,7 +595,17 @@ static int32_t shellLoadAppInternal(AppContextT *ctx, const char *path, const ch
             // The app creates its windows and returns. From this point on,
             // the app lives entirely through event callbacks dispatched by
             // the shell's dvxUpdate loop. No separate task or stack needed.
    -        app->entryFn(app->dxeCtx);
    +        // A non-zero return is the app declining to start: unload it the
    +        // same way a fault would, without running its shutdown hook.
    +        int32_t entryResult = app->entryFn(app->dxeCtx);
    +
    +        if (entryResult != 0) {
    +            dvxLog("Shell: '%s' appMain returned %ld, unloading", app->name, (long)entryResult);
    +            ctx->currentAppId = 0;
    +            dvxSetBusy(ctx, false);
    +            shellForceKillApp(ctx, app);
    +            return -1;
    +        }
         }
     
         // The slot pointer is stable across the callback-only entry point above
    diff --git a/src/libs/kpunch/dvxshell/shellApp.h b/src/libs/kpunch/dvxshell/shellApp.h
    index b3732ac..2f4e9b7 100644
    --- a/src/libs/kpunch/dvxshell/shellApp.h
    +++ b/src/libs/kpunch/dvxshell/shellApp.h
    @@ -187,6 +187,13 @@ ShellAppT *shellGetApp(int32_t appId);
     // Total number of app slots (for iteration bounds)
     int32_t shellAppSlotCount(void);
     
    +// Test seams (host harness only): drop every app slot / rebind the
    +// shell-side callback tables and task hooks to a harness-owned context.
    +void shellAppTestReset(void);
    +
    +int shellMain(int argc, char *argv[]);
    +void shellTestBind(AppContextT *ctx);
    +
     // Count running apps (not counting the shell itself)
     int32_t shellRunningAppCount(void);
     
    diff --git a/src/libs/kpunch/dvxshell/shellMain.c b/src/libs/kpunch/dvxshell/shellMain.c
    index 2d1708a..f94495d 100644
    --- a/src/libs/kpunch/dvxshell/shellMain.c
    +++ b/src/libs/kpunch/dvxshell/shellMain.c
    @@ -70,6 +70,7 @@
     // ============================================================
     
     static AppContextT  sCtx;
    +static AppContextT *sCtxRef = &sCtx;   // context the shell-side callbacks tag app ids from
     static PrefsHandleT *sPrefs = NULL;
     // setjmp buffer for crash recovery. The crash handler longjmps here to
     // return control to the shell's main loop after an app crashes.
    @@ -117,6 +118,7 @@ int         shellMain(int argc, char *argv[]);
     void        shellPurgeAppCallbacks(int32_t appId);
     void        shellRegisterDesktopUpdate(void (*updateFn)(void));
     void        shellRegisterIdle(void (*fn)(void *ctx), void *ctx);
    +void        shellTestBind(AppContextT *ctx);
     void        shellUnregisterDesktopUpdate(void (*updateFn)(void));
     void        shellUnregisterIdle(void (*fn)(void *ctx), void *ctx);
     static void titleChangeHandler(void *ctx);
    @@ -127,12 +129,12 @@ static void titleChangeHandler(void *ctx);
     // save/restore on every switch, the first yield back from an app left
     // the shell's allocations and windows attributed to that app.
     static void appIdRestore(int32_t value) {
    -    sCtx.currentAppId = value;
    +    sCtxRef->currentAppId = value;
     }
     
     
     static int32_t appIdSave(void) {
    -    return sCtx.currentAppId;
    +    return sCtxRef->currentAppId;
     }
     
     
    @@ -221,88 +223,6 @@ static void shellIdleDispatch(void *ctx) {
     }
     
     
    -// Drop every idle and desktop-update callback registered by the given app,
    -// matched on the appId captured when each was registered.  shellForceKillApp
    -// (and, defensively, shellReapApp) calls this before dlclose so a force or
    -// crash kill reclaims an app's shell-side registrations WITHOUT running the
    -// app's own -- possibly hung or faulting -- shutdownFn.  Otherwise the
    -// pointers would dangle into unmapped DXE code and the next shellDesktopUpdate
    -// or idle frame would jump into it.  appId < 1 (the shell's own and
    -// persistent-DXE registrations) is never purged.  Both lists are walked
    -// back-to-front so arrdel's index shifts cannot skip a matching entry.
    -void shellPurgeAppCallbacks(int32_t appId) {
    -    if (appId < 1) {
    -        return;
    -    }
    -
    -    for (int32_t i = arrlen(sDesktopUpdateFns) - 1; i >= 0; i--) {
    -        if (sDesktopUpdateFns[i].appId == appId) {
    -            arrdel(sDesktopUpdateFns, i);
    -        }
    -    }
    -
    -    for (int32_t i = arrlen(sIdleHandlers) - 1; i >= 0; i--) {
    -        if (sIdleHandlers[i].appId == appId) {
    -            arrdel(sIdleHandlers, i);
    -        }
    -    }
    -}
    -
    -
    -void shellRegisterDesktopUpdate(void (*updateFn)(void)) {
    -    DesktopUpdateHandlerT handler;
    -
    -    handler.fn    = updateFn;
    -    handler.appId = sCtx.currentAppId;
    -    arrput(sDesktopUpdateFns, handler);
    -}
    -
    -
    -// Idempotent: a (fn, ctx) pair already present is not added twice, so a
    -// producer can call this on every attach without tracking its own state.
    -void shellRegisterIdle(void (*fn)(void *ctx), void *ctx) {
    -    IdleHandlerT handler;
    -
    -    for (int32_t i = 0; i < arrlen(sIdleHandlers); i++) {
    -        if (sIdleHandlers[i].fn == fn && sIdleHandlers[i].ctx == ctx) {
    -            return;
    -        }
    -    }
    -
    -    handler.fn    = fn;
    -    handler.ctx   = ctx;
    -    handler.appId = sCtx.currentAppId;
    -    arrput(sIdleHandlers, handler);
    -}
    -
    -
    -void shellUnregisterDesktopUpdate(void (*updateFn)(void)) {
    -    for (int32_t i = 0; i < arrlen(sDesktopUpdateFns); i++) {
    -        if (sDesktopUpdateFns[i].fn == updateFn) {
    -            arrdel(sDesktopUpdateFns, i);
    -            return;
    -        }
    -    }
    -}
    -
    -
    -// No-op if the (fn, ctx) pair is absent, so double-unregister is safe.
    -void shellUnregisterIdle(void (*fn)(void *ctx), void *ctx) {
    -    for (int32_t i = 0; i < arrlen(sIdleHandlers); i++) {
    -        if (sIdleHandlers[i].fn == fn && sIdleHandlers[i].ctx == ctx) {
    -            arrdel(sIdleHandlers, i);
    -            return;
    -        }
    -    }
    -}
    -
    -
    -static void titleChangeHandler(void *ctx) {
    -    (void)ctx;
    -    shellDesktopUpdate();
    -}
    -
    -
     int shellMain(int argc, char *argv[]) {
         (void)argc;
         (void)argv;
    @@ -316,6 +236,8 @@ int shellMain(int argc, char *argv[]) {
         if (tsInit() != TS_OK) {
             dvxLog("Failed to initialize task system");
             // dvxInit has not run yet at this point, so there is nothing to shut down.
    +        prefsClose(sPrefs);
    +        sPrefs = NULL;
             return 1;
         }
     
    @@ -358,6 +280,8 @@ int shellMain(int argc, char *argv[]) {
             if (result != 0) {
                 dvxLog("Failed to initialize DVX GUI (error %ld)", (long)result);
                 tsShutdown();
    +            prefsClose(sPrefs);
    +            sPrefs = NULL;
                 return 1;
             }
     
    @@ -453,6 +377,8 @@ int shellMain(int argc, char *argv[]) {
             dvxLog("Failed to load desktop app '%s'", desktopApp);
             tsShutdown();
             dvxShutdown(&sCtx);
    +        prefsClose(sPrefs);
    +        sPrefs = NULL;
             return 1;
         }
     
    @@ -557,3 +483,109 @@ int shellMain(int argc, char *argv[]) {
         dvxLog("DVX Shell exited.");
         return 0;
     }
    +
    +
    +// Drop every idle and desktop-update callback registered by the given app,
    +// matched on the appId captured when each was registered.  shellForceKillApp
    +// (and, defensively, shellReapApp) calls this before dlclose so a force or
    +// crash kill reclaims an app's shell-side registrations WITHOUT running the
    +// app's own -- possibly hung or faulting -- shutdownFn.  Otherwise the
    +// pointers would dangle into unmapped DXE code and the next shellDesktopUpdate
    +// or idle frame would jump into it.  appId < 1 (the shell's own and
    +// persistent-DXE registrations) is never purged.  Both lists are walked
    +// back-to-front so arrdel's index shifts cannot skip a matching entry.
    +void shellPurgeAppCallbacks(int32_t appId) {
    +    if (appId < 1) {
    +        return;
    +    }
    +
    +    for (int32_t i = arrlen(sDesktopUpdateFns) - 1; i >= 0; i--) {
    +        if (sDesktopUpdateFns[i].appId == appId) {
    +            arrdel(sDesktopUpdateFns, i);
    +        }
    +    }
    +
    +    for (int32_t i = arrlen(sIdleHandlers) - 1; i >= 0; i--) {
    +        if (sIdleHandlers[i].appId == appId) {
    +            arrdel(sIdleHandlers, i);
    +        }
    +    }
    +}
    +
    +
    +void shellRegisterDesktopUpdate(void (*updateFn)(void)) {
    +    DesktopUpdateHandlerT handler;
    +
    +    handler.fn    = updateFn;
    +    handler.appId = sCtxRef->currentAppId;
    +    arrput(sDesktopUpdateFns, handler);
    +}
    +
    +
    +// Idempotent: a (fn, ctx) pair already present is not added twice, so a
    +// producer can call this on every attach without tracking its own state.
    +void shellRegisterIdle(void (*fn)(void *ctx), void *ctx) {
    +    IdleHandlerT handler;
    +
    +    for (int32_t i = 0; i < arrlen(sIdleHandlers); i++) {
    +        if (sIdleHandlers[i].fn == fn && sIdleHandlers[i].ctx == ctx) {
    +            return;
    +        }
    +    }
    +
    +    handler.fn    = fn;
    +    handler.ctx   = ctx;
    +    handler.appId = sCtxRef->currentAppId;
    +    arrput(sIdleHandlers, handler);
    +}
    +
    +
    +// Test seam: run the shell-side app machinery against a harness-owned
    +// context instead of the shell's private one.  Mirrors the wiring shellMain
    +// performs (slot table, memory attribution, task context hooks, idle
    +// dispatch) and drops every handler and slot left by the previous case.
    +// Pass NULL to return to the shell's own context.
    +void shellTestBind(AppContextT *ctx) {
    +    arrfree(sDesktopUpdateFns);
    +    arrfree(sIdleHandlers);
    +    sDesktopUpdateFns = NULL;
    +    sIdleHandlers     = NULL;
    +    sCtxRef           = ctx ? ctx : &sCtx;
    +    shellAppTestReset();
    +
    +    if (ctx) {
    +        shellAppInit();
    +        dvxMemAppIdPtr = &ctx->currentAppId;
    +        tsSetContextHooks(appIdSave, appIdRestore);
    +        ctx->idleCallback = shellIdleDispatch;
    +        ctx->idleCtx      = ctx;
    +    }
    +}
    +
    +
    +void shellUnregisterDesktopUpdate(void (*updateFn)(void)) {
    +    for (int32_t i = 0; i < arrlen(sDesktopUpdateFns); i++) {
    +        if (sDesktopUpdateFns[i].fn == updateFn) {
    +            arrdel(sDesktopUpdateFns, i);
    +            return;
    +        }
    +    }
    +}
    +
    +
    +// No-op if the (fn, ctx) pair is absent, so double-unregister is safe.
    +void shellUnregisterIdle(void (*fn)(void *ctx), void *ctx) {
    +    for (int32_t i = 0; i < arrlen(sIdleHandlers); i++) {
    +        if (sIdleHandlers[i].fn == fn && sIdleHandlers[i].ctx == ctx) {
    +            arrdel(sIdleHandlers, i);
    +            return;
    +        }
    +    }
    +}
    +
    +
    +static void titleChangeHandler(void *ctx) {
    +    (void)ctx;
    +    shellDesktopUpdate();
    +}
    +
    diff --git a/src/libs/kpunch/libdvx/Makefile b/src/libs/kpunch/libdvx/Makefile
    index 59d976b..d9f7ad8 100644
    --- a/src/libs/kpunch/libdvx/Makefile
    +++ b/src/libs/kpunch/libdvx/Makefile
    @@ -44,10 +44,45 @@ OBJS   = $(patsubst %.c,$(OBJDIR)/%.o,$(SRCS))
     TARGETDIR = $(LIBSDIR)/kpunch/libdvx
     TARGET    = $(TARGETDIR)/libdvx.lib
     
    -.PHONY: all clean
    +# Native host build (test harness): same core sources plus the host
    +# platform backend and the shared platform pieces the DOS loader normally
    +# supplies (memory tracking, util, stb_ds).  Produces a static archive the
    +# test binaries link with --whole-archive so widget .so files can resolve
    +# every core symbol through -rdynamic.  SAN=1 adds ASan/UBSan.
    +HOSTCC        = gcc
    +HOSTOBJDIR    = ../../../../obj/host
    +HOSTTARGET    = $(HOSTOBJDIR)/libdvx.a
    +HOSTCFLAGS    = -O1 -g -fPIC -Wall -Wextra -Werror -Wno-type-limits -Wno-sign-compare -Wno-format-truncation -D_GNU_SOURCE -I. -Iplatform -I../libtasks -Ithirdparty
    +ifeq ($(SAN),1)
    +HOSTCFLAGS   += -fsanitize=address,undefined -fno-omit-frame-pointer
    +endif
    +# COV=1 (make coverage): gcov instrumentation, objects kept apart from
    +# the sanitized build under obj/hostcov.
    +ifeq ($(COV),1)
    +HOSTOBJDIR    = ../../../../obj/hostcov
    +HOSTCFLAGS   += --coverage
    +endif
    +HOST_SRCS     = $(SRCS) platform/dvxPlatformHost.c platform/dvxMemTrack.c platform/dvxPlatformUtil.c thirdparty/stb_ds_impl.c
    +HOST_OBJS     = $(patsubst %.c,$(HOSTOBJDIR)/libdvx/%.o,$(HOST_SRCS))
    +HOST_HDRS     = $(wildcard *.h platform/*.h thirdparty/*.h)
    +
    +.PHONY: all clean host host-clean
     
     all: $(TARGET) $(TARGETDIR)/libdvx.dep
     
    +host: $(HOSTTARGET)
    +
    +$(HOSTTARGET): $(HOST_OBJS)
    +	rm -f $@
    +	ar rcs $@ $(HOST_OBJS)
    +
    +$(HOSTOBJDIR)/libdvx/%.o: %.c $(HOST_HDRS)
    +	@mkdir -p $(dir $@)
    +	$(HOSTCC) $(HOSTCFLAGS) -c -o $@ $<
    +
    +host-clean:
    +	rm -rf $(HOSTOBJDIR)/libdvx $(HOSTTARGET)
    +
     $(TARGETDIR)/libdvx.dep: libdvx.dep | $(TARGETDIR)
     	sed 's/$$/\r/' $< > $@
     
    diff --git a/src/libs/kpunch/libdvx/dvxApp.c b/src/libs/kpunch/libdvx/dvxApp.c
    index 51e54c9..cd628ac 100644
    --- a/src/libs/kpunch/libdvx/dvxApp.c
    +++ b/src/libs/kpunch/libdvx/dvxApp.c
    @@ -65,14 +65,13 @@
     
     #include 
     #include 
    -#include 
     
     #include "thirdparty/stb_image_wrap.h"
     #include "thirdparty/stb_image_write_wrap.h"
     
    -// Double-click timing uses CLOCKS_PER_SEC so it's portable between DJGPP
    -// (where CLOCKS_PER_SEC is typically 91, from the PIT) and Linux/SDL.
    -#define DBLCLICK_THRESHOLD     (CLOCKS_PER_SEC / 2)
    +// Default double-click interval in milliseconds; converted to platform
    +// ticks at init via PLATFORM_MS_TO_TICKS.
    +#define DBLCLICK_DEFAULT_MS    500
     
     // Minimized icon thumbnails are refreshed in a round-robin fashion rather
     // than all at once, spreading the repaint cost across multiple frames.
    @@ -180,8 +179,8 @@ static void drawCursorAt(AppContextT *ctx, int32_t x, int32_t y);
     static void drawPopupLevel(AppContextT *ctx, DisplayT *d, const BlitOpsT *ops, const MenuT *menu, int32_t px, int32_t py, int32_t pw, int32_t ph, int32_t hoverItem, const RectT *clipTo);
     static void enumModeCb(int32_t w, int32_t h, int32_t bpp, void *userData);
     static void executeSysMenuCmd(AppContextT *ctx, int32_t cmd);
    -static uint8_t *finishLoadImage(const DisplayT *d, uint8_t *rgb, int32_t imgW, int32_t imgH, int32_t *outW, int32_t *outH, int32_t *outPitch);
     static WindowT *findWindowById(AppContextT *ctx, int32_t id);
    +static uint8_t *finishLoadImage(const DisplayT *d, uint8_t *rgb, int32_t imgW, int32_t imgH, int32_t *outW, int32_t *outH, int32_t *outPitch);
     static void flushPendingDestroys(AppContextT *ctx);
     static void focusTopmostAlive(AppContextT *ctx);
     static void handleMouseButton(AppContextT *ctx, int32_t mx, int32_t my, int32_t buttons);
    @@ -195,6 +194,7 @@ static void openContextMenu(AppContextT *ctx, WindowT *win, MenuT *menu, int32_t
     static void openPopupAtMenu(AppContextT *ctx, WindowT *win, int32_t menuIdx);
     static void openSubMenu(AppContextT *ctx);
     static void openSysMenu(AppContextT *ctx, WindowT *win);
    +static void packColorScheme(AppContextT *ctx);
     static void pollKeyboard(AppContextT *ctx);
     static void pollMouse(AppContextT *ctx);
     static void pollWidgets(AppContextT *ctx);
    @@ -362,17 +362,17 @@ static uint8_t *buildWallpaperBuf(AppContextT *ctx, const uint8_t *rgb, int32_t
         if (bytesPerPx == 1) {
             memset(buf, bgPx & 0xFF, pitch * screenH);
         } else if (bytesPerPx == 2) {
    -        uint16_t px16 = (uint16_t)bgPx;
    -        uint32_t fill32 = ((uint32_t)px16 << 16) | px16;
    -        for (int32_t y = 0; y < screenH; y++) {
    -            uint32_t *row = (uint32_t *)(buf + y * pitch);
    -            int32_t pairs = screenW / 2;
    -            for (int32_t i = 0; i < pairs; i++) {
    -                row[i] = fill32;
    -            }
    -            if (screenW & 1) {
    -                *(uint16_t *)(buf + y * pitch + (screenW - 1) * 2) = px16;
    -            }
    +        // Fill the first row 16 bits at a time (a row start is only
    +        // guaranteed 2-byte aligned), then replicate it with memcpy.
    +        uint16_t  px16 = (uint16_t)bgPx;
    +        uint16_t *row0 = (uint16_t *)buf;
    +
    +        for (int32_t x = 0; x < screenW; x++) {
    +            row0[x] = px16;
    +        }
    +
    +        for (int32_t y = 1; y < screenH; y++) {
    +            memcpy(buf + y * pitch, buf, (size_t)screenW * 2);
             }
         } else {
             uint32_t *row32 = (uint32_t *)buf;
    @@ -906,8 +906,14 @@ static void compositeAndFlush(AppContextT *ctx) {
             RectT *dr = &dl->rects[i];
     
             // Clip dirty rect to screen bounds
    -        if (dr->x < 0) { dr->w += dr->x; dr->x = 0; }
    -        if (dr->y < 0) { dr->h += dr->y; dr->y = 0; }
    +        if (dr->x < 0) {
    +            dr->w += dr->x;
    +            dr->x = 0;
    +        }
    +        if (dr->y < 0) {
    +            dr->h += dr->y;
    +            dr->y = 0;
    +        }
             if (dr->x + dr->w > d->width)  { dr->w = d->width - dr->x; }
             if (dr->y + dr->h > d->height) { dr->h = d->height - dr->y; }
             if (dr->w <= 0 || dr->h <= 0) { continue; }
    @@ -2021,6 +2027,1719 @@ static void drawPopupLevel(AppContextT *ctx, DisplayT *d, const BlitOpsT *ops, c
     }
     
     
    +// ============================================================
    +// dvxAddAccel
    +// ============================================================
    +//
    +// Accelerator entries are pre-normalized at registration time: the key
    +// is uppercased and modifier bits are masked to just Ctrl|Alt. This
    +// moves the normalization cost from the hot path (every keypress) to
    +// the cold path (one-time setup), so checkAccelTable can do a simple
    +// integer compare per entry.
    +
    +void dvxAddAccel(AccelTableT *table, int32_t key, int32_t modifiers, int32_t cmdId) {
    +    if (!table) {
    +        return;
    +    }
    +
    +    if (table->count >= table->cap) {
    +        int32_t newCap = table->cap ? table->cap * 2 : 8;
    +        AccelEntryT *newBuf = (AccelEntryT *)realloc(table->entries, newCap * sizeof(AccelEntryT));
    +
    +        if (!newBuf) {
    +            return;
    +        }
    +
    +        table->entries = newBuf;
    +        table->cap     = newCap;
    +    }
    +
    +    int32_t normKey = key;
    +
    +    if (normKey >= 'a' && normKey <= 'z') {
    +        normKey = normKey - 32;
    +    }
    +
    +    AccelEntryT *e = &table->entries[table->count++];
    +    e->key       = key;
    +    e->modifiers = modifiers;
    +    e->cmdId     = cmdId;
    +    e->normKey   = normKey;
    +    e->normMods  = modifiers & (ACCEL_CTRL | ACCEL_ALT);
    +}
    +
    +
    +// ============================================================
    +// dvxApplyColorScheme
    +// ============================================================
    +
    +void dvxApplyColorScheme(AppContextT *ctx) {
    +    packColorScheme(ctx);
    +    invalidateAllWindows(ctx);
    +}
    +
    +
    +void dvxAppTestReset(void) {
    +    sKeyPressedBtn   = NULL;
    +    sCursorBlinkFn   = NULL;
    +    sTooltipShownGen = 0;
    +}
    +
    +
    +// ============================================================
    +// dvxCascadeWindows
    +// ============================================================
    +//
    +// Arranges windows in the classic cascade pattern: each window is the same
    +// size (2/3 of screen), offset diagonally by the title bar height so each
    +// title bar remains visible. When the cascade would go off-screen, it wraps
    +// back to (0,0). This matches the Windows 3.x cascade behavior.
    +// The step size is title_height + border_width so exactly one title bar's
    +// worth of the previous window peeks out above and to the left.
    +
    +void dvxCascadeWindows(AppContextT *ctx) {
    +    int32_t screenW = ctx->display.width;
    +    int32_t screenH = ctx->display.height;
    +    int32_t offsetX = 0;
    +    int32_t offsetY = 0;
    +    int32_t step    = CHROME_TITLEBAR_BOTTOM;
    +
    +    int32_t winW = screenW * CASCADE_SIZE_NUMER / CASCADE_SIZE_DENOM;
    +    int32_t winH = screenH * CASCADE_SIZE_NUMER / CASCADE_SIZE_DENOM;
    +
    +    if (winW < MIN_WINDOW_W) {
    +        winW = MIN_WINDOW_W;
    +    }
    +
    +    if (winH < MIN_WINDOW_H) {
    +        winH = MIN_WINDOW_H;
    +    }
    +
    +    for (int32_t i = 0; i < ctx->stack.count; i++) {
    +        WindowT *win = ctx->stack.windows[i];
    +
    +        if (win->minimized || !win->visible) {
    +            continue;
    +        }
    +
    +        repositionWindow(ctx, win, offsetX, offsetY, winW, winH);
    +
    +        offsetX += step;
    +        offsetY += step;
    +
    +        // Wrap around if we'd go off screen
    +        if (offsetX + winW > screenW || offsetY + winH > screenH) {
    +            offsetX = 0;
    +            offsetY = 0;
    +        }
    +    }
    +}
    +
    +
    +// ============================================================
    +// dvxChangeVideoMode
    +// ============================================================
    +//
    +// Live video mode switch.  Saves the old display state, attempts to
    +// set the new mode, and if successful, reinitializes all dependent
    +// subsystems: blit ops, colors, cursors, mouse range, wallpaper,
    +// and all window content buffers.  Windows larger than the new
    +// screen are clamped.  On failure, the old mode is restored.
    +
    +int32_t dvxChangeVideoMode(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
    +    // Save old state for rollback
    +    DisplayT oldDisplay = ctx->display;
    +
    +    // Stash old wallpaper (don't free  -- we may need it for rollback)
    +    uint8_t *oldWpBuf   = ctx->wallpaperBuf;
    +    int32_t  oldWpPitch = ctx->wallpaperPitch;
    +    ctx->wallpaperBuf   = NULL;
    +    ctx->wallpaperPitch = 0;
    +
    +    // Free old video buffers (no text mode restore)
    +    platformVideoFreeBuffers(&ctx->display);
    +
    +    // Try the new mode
    +    if (videoInit(&ctx->display, requestedW, requestedH, preferredBpp) != 0) {
    +        // Restore old mode
    +        ctx->display = oldDisplay;
    +        ctx->display.backBuf = NULL;
    +        ctx->display.palette = NULL;
    +
    +        if (videoInit(&ctx->display, oldDisplay.width, oldDisplay.height, oldDisplay.format.bitsPerPixel) != 0) {
    +            // Both failed  -- catastrophic
    +            return -1;
    +        }
    +
    +        // Restore wallpaper
    +        ctx->wallpaperBuf   = oldWpBuf;
    +        ctx->wallpaperPitch = oldWpPitch;
    +        drawInit(&ctx->blitOps, &ctx->display);
    +        dvxApplyColorScheme(ctx);
    +
    +        // The re-created backbuffer is blank: queue a full repaint or
    +        // the desktop stays black until something else dirties it.
    +        resetClipRect(&ctx->display);
    +        dirtyListInit(&ctx->dirty);
    +        dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    +        return -1;
    +    }
    +
    +    // New mode succeeded  -- free old wallpaper buffer
    +    free(oldWpBuf);
    +
    +    // Reinit blit ops for new pixel format
    +    drawInit(&ctx->blitOps, &ctx->display);
    +
    +    // Repack all colors for the new pixel format.  Painting must wait:
    +    // the window content buffers below are still sized for the OLD
    +    // format, so invalidating here would overrun them.  wmResizeCommit
    +    // reallocates and repaints each window in the loop that follows.
    +    packColorScheme(ctx);
    +
    +    // Reinit mouse range
    +    platformMouseInit(ctx->display.width, ctx->display.height);
    +    ctx->hasMouseWheel = platformMouseWheelInit();
    +
    +    // Clamp mouse position to new screen
    +    if (ctx->mouseX >= ctx->display.width) {
    +        ctx->mouseX = ctx->display.width - 1;
    +    }
    +
    +    if (ctx->mouseY >= ctx->display.height) {
    +        ctx->mouseY = ctx->display.height - 1;
    +    }
    +
    +    // Clamp and reallocate all window content buffers
    +    for (int32_t i = 0; i < ctx->stack.count; i++) {
    +        WindowT *win = ctx->stack.windows[i];
    +
    +        // Clamp window position to new screen bounds
    +        if (win->x + win->w > ctx->display.width) {
    +            win->x = ctx->display.width - win->w;
    +        }
    +
    +        if (win->x < 0) {
    +            win->x = 0;
    +            win->w = ctx->display.width;
    +        }
    +
    +        if (win->y + win->h > ctx->display.height) {
    +            win->y = ctx->display.height - win->h;
    +        }
    +
    +        if (win->y < 0) {
    +            win->y = 0;
    +            win->h = ctx->display.height;
    +        }
    +
    +        // Clear maximized flag since screen size changed
    +        win->maximized = false;
    +
    +        WIN_CALLBACK(ctx, win, wmResizeCommit(win, &ctx->display));
    +    }
    +
    +    // Reload wallpaper at the new resolution/bpp
    +    if (ctx->wallpaperPath[0]) {
    +        dvxSetWallpaper(ctx, ctx->wallpaperPath);
    +    }
    +
    +    // Reset clip and dirty the full screen
    +    resetClipRect(&ctx->display);
    +    dirtyListInit(&ctx->dirty);
    +    dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    +
    +    return 0;
    +}
    +
    +
    +// ============================================================
    +// dvxClipboardCopy / dvxClipboardGet
    +// ============================================================
    +//
    +// The clipboard is a simple in-process text buffer managed by the
    +// platform layer (clipboardCopy/clipboardGet). There is no inter-process
    +// clipboard because DVX runs as a single-process windowing system  -- all
    +// windows share the same address space. The thin wrappers here exist to
    +// keep the platform layer out of application code's include path.
    +
    +void dvxClipboardCopy(const char *text, int32_t len) {
    +    clipboardCopy(text, len);
    +}
    +
    +
    +const char *dvxClipboardGet(int32_t *outLen) {
    +    return clipboardGet(outLen);
    +}
    +
    +
    +// ============================================================
    +// dvxColorLabel
    +// ============================================================
    +
    +const char *dvxColorLabel(ColorIdE id) {
    +    if (id < 0 || id >= ColorCountE) {
    +        return "Unknown";
    +    }
    +
    +    return sColorLabels[id];
    +}
    +
    +
    +// ============================================================
    +// dvxColorName
    +// ============================================================
    +
    +const char *dvxColorName(ColorIdE id) {
    +    if (id < 0 || id >= ColorCountE) {
    +        return "unknown";
    +    }
    +
    +    return sColorNames[id];
    +}
    +
    +
    +// ============================================================
    +// dvxCreateAccelTable
    +// ============================================================
    +
    +AccelTableT *dvxCreateAccelTable(void) {
    +    AccelTableT *table = (AccelTableT *)calloc(1, sizeof(AccelTableT));
    +    return table;
    +}
    +
    +
    +// ============================================================
    +// dvxCreateWindow
    +// ============================================================
    +
    +WindowT *dvxCreateWindow(AppContextT *ctx, const char *title, int32_t x, int32_t y, int32_t w, int32_t h, bool resizable) {
    +    // Auto-cascade: if another window already occupies this exact position,
    +    // offset diagonally by the title bar height so the new window doesn't
    +    // sit directly on top.  Keeps offsetting while collisions exist, wrapping
    +    // back to the origin if we'd go off screen.
    +    int32_t step = CHROME_TITLEBAR_BOTTOM;
    +
    +    for (;;) {
    +        bool collision = false;
    +
    +        for (int32_t i = 0; i < ctx->stack.count; i++) {
    +            WindowT *other = ctx->stack.windows[i];
    +
    +            if (other->x == x && other->y == y) {
    +                collision = true;
    +                break;
    +            }
    +        }
    +
    +        if (!collision) {
    +            break;
    +        }
    +
    +        x += step;
    +        y += step;
    +
    +        if (x + w > ctx->display.width || y + h > ctx->display.height) {
    +            x = step;
    +            y = step;
    +            break;
    +        }
    +    }
    +
    +    // Clamp to screen so the window is fully visible
    +    if (x + w > ctx->display.width) {
    +        x = ctx->display.width - w;
    +    }
    +
    +    if (y + h > ctx->display.height) {
    +        y = ctx->display.height - h;
    +    }
    +
    +    if (x < 0) {
    +        x = 0;
    +    }
    +
    +    if (y < 0) {
    +        y = 0;
    +    }
    +
    +    WindowT *win = wmCreateWindow(&ctx->stack, &ctx->display, title, x, y, w, h, resizable);
    +
    +    if (win) {
    +        // Stamp window with the current app ID (set by the shell before
    +        // calling app entry points). Enables per-app window tracking for
    +        // cleanup on crash/termination.
    +        win->appId = ctx->currentAppId;
    +
    +        // Raise and focus
    +        int32_t idx = ctx->stack.count - 1;
    +        wmSetFocus(&ctx->stack, &ctx->dirty, idx);
    +        dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    +    }
    +
    +    return win;
    +}
    +
    +
    +// ============================================================
    +// dvxCreateWindowCentered
    +// ============================================================
    +
    +WindowT *dvxCreateWindowCentered(AppContextT *ctx, const char *title, int32_t w, int32_t h, bool resizable) {
    +    int32_t x = (ctx->display.width - w) / 2;
    +    int32_t y = (ctx->display.height - h) / 2;
    +
    +    return dvxCreateWindow(ctx, title, x, y, w, h, resizable);
    +}
    +
    +
    +void dvxDestroyWindow(AppContextT *ctx, WindowT *win) {
    +    destroyWindowPrep(ctx, win);
    +
    +    // A user callback is on the stack: defer the teardown so the
    +    // dispatch code's widget pointers stay valid until it unwinds.
    +    if (ctx->dispatchDepth > 0) {
    +        deferDestroyWindow(ctx, win);
    +        return;
    +    }
    +
    +    destroyWindowSync(ctx, win);
    +}
    +
    +
    +void dvxDestroyWindowNow(AppContextT *ctx, WindowT *win) {
    +    destroyWindowPrep(ctx, win);
    +
    +    // A previously deferred window is destroyed here and now; drop its
    +    // queued state so the flush hint stays balanced.
    +    if (win->destroyPending) {
    +        win->destroyPending = false;
    +
    +        if (ctx->pendingDestroyCount > 0) {
    +            ctx->pendingDestroyCount--;
    +        }
    +    }
    +
    +    destroyWindowSync(ctx, win);
    +}
    +
    +
    +// Resizes a window to exactly fit its widget tree's minimum size,
    +// accounting for chrome overhead (title bar, borders, optional menu bar).
    +// Used after building a dialog's widget tree to size the dialog
    +// automatically rather than requiring the caller to compute sizes manually.
    +
    +void dvxFitWindow(AppContextT *ctx, WindowT *win) {
    +    if (!ctx || !win || !win->widgetRoot) {
    +        return;
    +    }
    +
    +    widgetCalcMinSizeTree(win->widgetRoot, &ctx->font);
    +
    +    int32_t topChrome = CHROME_TOTAL_TOP;
    +
    +    if (win->menuBar) {
    +        topChrome += CHROME_MENU_HEIGHT;
    +    }
    +
    +    int32_t newW = win->widgetRoot->calcMinW + CHROME_TOTAL_SIDE * 2;
    +    int32_t newH = win->widgetRoot->calcMinH + topChrome + CHROME_TOTAL_BOTTOM;
    +
    +    // Ensure the window is at least as wide/tall as the WM minimum
    +    // (accounts for menu bar width, title bar gadgets, etc.)
    +    int32_t wmMinW;
    +    int32_t wmMinH;
    +    wmMinWindowSize(win, &wmMinW, &wmMinH);
    +
    +    if (newW < wmMinW) {
    +        newW = wmMinW;
    +    }
    +
    +    if (newH < wmMinH) {
    +        newH = wmMinH;
    +    }
    +
    +    dvxResizeWindow(ctx, win, newW, newH);
    +}
    +
    +
    +void dvxFitWindowH(AppContextT *ctx, WindowT *win) {
    +    if (!ctx || !win || !win->widgetRoot) {
    +        return;
    +    }
    +
    +    widgetCalcMinSizeTree(win->widgetRoot, &ctx->font);
    +
    +    int32_t topChrome = CHROME_TOTAL_TOP;
    +
    +    if (win->menuBar) {
    +        topChrome += CHROME_MENU_HEIGHT;
    +    }
    +
    +    int32_t newH = win->widgetRoot->calcMinH + topChrome + CHROME_TOTAL_BOTTOM;
    +
    +    dvxResizeWindow(ctx, win, win->w, newH);
    +}
    +
    +
    +void dvxFitWindowW(AppContextT *ctx, WindowT *win) {
    +    if (!ctx || !win || !win->widgetRoot) {
    +        return;
    +    }
    +
    +    widgetCalcMinSizeTree(win->widgetRoot, &ctx->font);
    +
    +    int32_t newW = win->widgetRoot->calcMinW + CHROME_TOTAL_SIDE * 2;
    +
    +    dvxResizeWindow(ctx, win, newW, win->h);
    +}
    +
    +
    +// ============================================================
    +// dvxFreeAccelTable
    +// ============================================================
    +
    +void dvxFreeAccelTable(AccelTableT *table) {
    +    if (table) {
    +        free(table->entries);
    +    }
    +
    +    free(table);
    +}
    +
    +
    +// ============================================================
    +// dvxFreeImage
    +// ============================================================
    +
    +void dvxFreeImage(uint8_t *data) {
    +    free(data);
    +}
    +
    +
    +const BlitOpsT *dvxGetBlitOps(const AppContextT *ctx) {
    +    return &ctx->blitOps;
    +}
    +
    +
    +// ============================================================
    +// dvxGetColor
    +// ============================================================
    +
    +void dvxGetColor(const AppContextT *ctx, ColorIdE id, uint8_t *r, uint8_t *g, uint8_t *b) {
    +    if (id < 0 || id >= ColorCountE) {
    +        *r = *g = *b = 0;
    +        return;
    +    }
    +
    +    *r = ctx->colorRgb[id][0];
    +    *g = ctx->colorRgb[id][1];
    +    *b = ctx->colorRgb[id][2];
    +}
    +
    +
    +// ============================================================
    +// dvxGetColors
    +// ============================================================
    +
    +const ColorSchemeT *dvxGetColors(const AppContextT *ctx) {
    +    return &ctx->colors;
    +}
    +
    +
    +// ============================================================
    +// dvxGetDisplay
    +// ============================================================
    +
    +DisplayT *dvxGetDisplay(AppContextT *ctx) {
    +    return &ctx->display;
    +}
    +
    +
    +// ============================================================
    +// dvxGetFont
    +// ============================================================
    +
    +const BitmapFontT *dvxGetFont(const AppContextT *ctx) {
    +    return &ctx->font;
    +}
    +
    +
    +// ============================================================
    +// dvxGetVideoModes
    +// ============================================================
    +
    +const VideoModeInfoT *dvxGetVideoModes(const AppContextT *ctx, int32_t *count) {
    +    *count = (int32_t)arrlen(ctx->videoModes);
    +    return ctx->videoModes;
    +}
    +
    +
    +void dvxHideWindow(AppContextT *ctx, WindowT *win) {
    +    if (!win) {
    +        return;
    +    }
    +
    +    // A hidden window must not stay registered as the modal window, or
    +    // the modal input gate would drop all clicks and deadlock input.
    +    // The callee owns this invariant (mirroring dvxDestroyWindow), and
    +    // it runs before the visibility early-return so the clear is
    +    // idempotent even for already-hidden windows.
    +    if (ctx->modalWindow == win) {
    +        ctx->modalWindow = NULL;
    +    }
    +
    +    if (!win->visible) {
    +        return;
    +    }
    +
    +    dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    +    win->visible = false;
    +
    +    // Keyboard input follows focusedIdx unconditionally, so a hidden
    +    // window must not keep it. Move focus to the topmost visible window;
    +    // if there is none, drop focus entirely (blurring this window).
    +    if (ctx->stack.focusedIdx >= 0 && ctx->stack.windows[ctx->stack.focusedIdx] == win) {
    +        focusTopmostAlive(ctx);
    +
    +        if (ctx->stack.windows[ctx->stack.focusedIdx] == win) {
    +            ctx->stack.focusedIdx = -1;
    +            win->focused          = false;
    +
    +            if (win->onBlur) {
    +                WIN_CALLBACK(ctx, win, win->onBlur(win));
    +            }
    +        }
    +    }
    +}
    +
    +
    +bool dvxImageInfo(const char *path, int32_t *outW, int32_t *outH) {
    +    if (!path) {
    +        return false;
    +    }
    +
    +    int w = 0;
    +    int h = 0;
    +    int comp = 0;
    +
    +    if (stbi_info(path, &w, &h, &comp)) {
    +        if (outW) { *outW = w; }
    +        if (outH) { *outH = h; }
    +        return true;
    +    }
    +
    +    return false;
    +}
    +
    +
    +// ============================================================
    +// dvxInit
    +// ============================================================
    +//
    +// One-shot initialization of all GUI subsystems. The layered init order
    +// matters: video must be up before draw ops can be selected (since draw
    +// ops depend on pixel format), and colors must be packed after the
    +// display format is known.
    +
    +int32_t dvxInit(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
    +    memset(ctx, 0, sizeof(*ctx));
    +
    +    platformInit();
    +    platformKeyUpInit();
    +
    +    // Enumerate available video modes BEFORE setting one.  Some VBE
    +    // BIOSes return a stale or truncated mode list once a graphics
    +    // mode is active, so we must query while still in text mode.
    +    // (ctx was memset above, so videoModes is already NULL.)
    +    platformVideoEnumModes(enumModeCb, ctx);
    +
    +    if (videoInit(&ctx->display, requestedW, requestedH, preferredBpp) != 0) {
    +        dvxLog("App: video init failed for %dx%d@%dbpp", requestedW, requestedH, preferredBpp);
    +        // dvxShutdown never runs on this path; drop the mode list here.
    +        arrfree(ctx->videoModes);
    +        ctx->videoModes = NULL;
    +        return -1;
    +    }
    +
    +    // Draw ops are pixel-format-dependent function pointers (e.g., 16bpp
    +    // vs 32bpp span fill). Selected once here, then used everywhere.
    +    drawInit(&ctx->blitOps, &ctx->display);
    +
    +    wmInit(&ctx->stack);
    +    dirtyListInit(&ctx->dirty);
    +
    +    // 8x16 is the only font size currently supported. Fixed-width bitmap
    +    // fonts are used throughout because variable-width text measurement
    +    // would add complexity and cost on every text draw without much
    +    // benefit at 640x480 resolution.
    +    ctx->font = dvxFont8x16;
    +
    +    memcpy(ctx->cursors, dvxCursors, sizeof(dvxCursors));
    +    ctx->cursorId = CURSOR_ARROW;
    +
    +    initColorScheme(ctx);
    +
    +    platformMouseInit(ctx->display.width, ctx->display.height);
    +    ctx->hasMouseWheel = platformMouseWheelInit();
    +    ctx->mouseX        = ctx->display.width / 2;
    +    ctx->mouseY        = ctx->display.height / 2;
    +    ctx->prevMouseX    = ctx->mouseX;
    +    ctx->prevMouseY    = ctx->mouseY;
    +
    +    ctx->running            = true;
    +    ctx->lastIconClickId    = -1;
    +    ctx->lastIconClickTime  = 0;
    +    ctx->lastCloseClickId   = -1;
    +    ctx->lastCloseClickTime = 0;
    +    ctx->lastTitleClickId   = -1;
    +    ctx->lastTitleClickTime = 0;
    +    ctx->wheelDirection     = 1;
    +    ctx->wheelStep          = MOUSE_WHEEL_STEP_DEFAULT;
    +    ctx->dblClickTicks      = PLATFORM_MS_TO_TICKS(DBLCLICK_DEFAULT_MS);
    +    sDblClickTicks          = ctx->dblClickTicks;
    +
    +    // Pre-compute fixed-point 16.16 reciprocal of character height so
    +    // popup menu item index calculation can use multiply+shift instead
    +    // of division. On a 486, integer divide is 40+ cycles; this
    +    // reciprocal trick reduces it to ~10 cycles (imul + shr).
    +    ctx->charHeightRecip    = ((uint32_t)FP_ONE + (uint32_t)ctx->font.charHeight - 1) / (uint32_t)ctx->font.charHeight;
    +
    +    // Dirty the entire screen so the first compositeAndFlush paints everything
    +    dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    +
    +    return 0;
    +}
    +
    +
    +void dvxInvalidateRect(AppContextT *ctx, WindowT *win, int32_t x, int32_t y, int32_t w, int32_t h) {
    +    // Convert from content-relative to screen coordinates
    +    int32_t screenX = win->x + win->contentX + x;
    +    int32_t screenY = win->y + win->contentY + y;
    +
    +    dirtyListAdd(&ctx->dirty, screenX, screenY, w, h);
    +}
    +
    +
    +// ============================================================
    +// dvxInvalidateTooltip
    +// ============================================================
    +//
    +// Hides the on-screen tooltip when it borrows `text` (pass NULL to hide
    +// unconditionally).  ctx->tooltipText is a borrowed pointer: widget
    +// destruction and wgtSetTooltip reassignment can free the string it
    +// points at, so every such site must invalidate it here or the
    +// compositor would keep drawing freed memory until the mouse moves.
    +
    +void dvxInvalidateTooltip(AppContextT *ctx, const char *text) {
    +    if (!ctx || !ctx->tooltipText) {
    +        return;
    +    }
    +
    +    if (text && ctx->tooltipText != text) {
    +        return;
    +    }
    +
    +    dirtyListAdd(&ctx->dirty, ctx->tooltipX, ctx->tooltipY, ctx->tooltipW, ctx->tooltipH);
    +    ctx->tooltipText = NULL;
    +}
    +
    +
    +// ============================================================
    +// dvxInvalidateWindow
    +// ============================================================
    +
    +void dvxInvalidateWindow(AppContextT *ctx, WindowT *win) {
    +    // A window queued for deferred destruction is inert: its app state
    +    // (widget userData etc.) may already be torn down, so never paint it.
    +    if (win->destroyPending) {
    +        return;
    +    }
    +
    +    // Call the window's paint callback to update the content buffer
    +    // before marking the screen dirty. This means raw-paint apps only
    +    // need to call dvxInvalidateWindow  -- onPaint fires automatically.
    +    // Set PAINT_FULL for widget-managed windows: widgetOnPaint only
    +    // relays out + background-clears when paintNeeded >= PAINT_FULL,
    +    // and callers invalidating "the whole window" (theme changes,
    +    // scroll, color-scheme swaps) need that full repaint rather than
    +    // the dirty-widget-only partial repaint the default would give.
    +    win->paintNeeded = PAINT_FULL;
    +
    +    // Depth-bracket the callback (and the trailing writes) so an onPaint
    +    // that destroys its own window defers -- the writes below then land
    +    // on still-live memory and the flush reclaims the window afterwards.
    +    ctx->dispatchDepth++;
    +
    +    if (win->onPaint) {
    +        RectT fullRect = {0, 0, win->contentW, win->contentH};
    +        WIN_CALLBACK(ctx, win, win->onPaint(win, &fullRect));
    +    }
    +
    +    win->iconNeedsRefresh = true;
    +    dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    +
    +    ctx->dispatchDepth--;
    +
    +    if (ctx->dispatchDepth == 0 && ctx->pendingDestroyCount > 0) {
    +        flushPendingDestroys(ctx);
    +    }
    +}
    +
    +
    +// ============================================================
    +// dvxLoadImage
    +// ============================================================
    +//
    +// Public image loading API. Loads any image file supported by stb_image
    +// (BMP, PNG, JPEG, GIF, etc.) and converts the RGB pixels to the
    +// display's native pixel format for direct use with rectCopy, wgtImage,
    +// wgtImageButton, or any other pixel-data consumer. The caller owns the
    +// returned buffer and must free it with dvxFreeImage().
    +
    +uint8_t *dvxLoadImage(const AppContextT *ctx, const char *path, int32_t *outW, int32_t *outH, int32_t *outPitch) {
    +    if (!ctx || !path) {
    +        return NULL;
    +    }
    +
    +    int imgW;
    +    int imgH;
    +    int channels;
    +    uint8_t *rgb = stbi_load(path, &imgW, &imgH, &channels, RGB_CHANNELS);
    +
    +    return finishLoadImage(&ctx->display, rgb, imgW, imgH, outW, outH, outPitch);
    +}
    +
    +
    +uint8_t *dvxLoadImageAlpha(const AppContextT *ctx, const uint8_t *data, int32_t dataLen, int32_t *outW, int32_t *outH, int32_t *outPitch, bool *outHasAlpha, uint32_t *outKeyColor) {
    +    if (!ctx || !data || dataLen <= 0) {
    +        return NULL;
    +    }
    +
    +    const DisplayT *d = &ctx->display;
    +
    +    int imgW;
    +    int imgH;
    +    int channels;
    +    uint8_t *rgba = stbi_load_from_memory(data, dataLen, &imgW, &imgH, &channels, RGBA_CHANNELS);
    +
    +    if (!rgba) {
    +        return NULL;
    +    }
    +
    +    uint32_t keyColor = packColor(d, KEY_COLOR_R, KEY_COLOR_G, KEY_COLOR_B);
    +    int32_t  pitch;
    +    uint8_t *buf      = convertRgbToNative(d, rgba, RGBA_CHANNELS, imgW, imgH, keyColor, outHasAlpha, &pitch);
    +
    +    stbi_image_free(rgba);
    +
    +    if (!buf) {
    +        return NULL;
    +    }
    +
    +    if (outW) {
    +        *outW = imgW;
    +    }
    +
    +    if (outH) {
    +        *outH = imgH;
    +    }
    +
    +    if (outPitch) {
    +        *outPitch = pitch;
    +    }
    +
    +    if (outKeyColor) {
    +        *outKeyColor = keyColor;
    +    }
    +
    +    return buf;
    +}
    +
    +
    +uint8_t *dvxLoadImageFromMemory(const AppContextT *ctx, const uint8_t *data, int32_t dataLen, int32_t *outW, int32_t *outH, int32_t *outPitch) {
    +    if (!ctx || !data || dataLen <= 0) {
    +        return NULL;
    +    }
    +
    +    int imgW;
    +    int imgH;
    +    int channels;
    +    uint8_t *rgb = stbi_load_from_memory(data, dataLen, &imgW, &imgH, &channels, RGB_CHANNELS);
    +
    +    return finishLoadImage(&ctx->display, rgb, imgW, imgH, outW, outH, outPitch);
    +}
    +
    +
    +bool dvxLoadTheme(AppContextT *ctx, const char *filename) {
    +    FILE *fp = fopen(filename, "rb");
    +
    +    if (!fp) {
    +        return false;
    +    }
    +
    +    char line[256];
    +
    +    while (fgets(line, sizeof(line), fp)) {
    +        // Strip trailing whitespace
    +        char *end = line + strlen(line) - 1;
    +
    +        while (end >= line && (*end == '\r' || *end == '\n' || *end == ' ')) {
    +            *end-- = '\0';
    +        }
    +
    +        char *p = (char *)dvxSkipWs(line);
    +
    +        // Skip comments, blank lines, section headers
    +        if (*p == '\0' || *p == ';' || *p == '#' || *p == '[') {
    +            continue;
    +        }
    +
    +        // Parse key = r,g,b
    +        char *eq = strchr(p, '=');
    +
    +        if (!eq) {
    +            continue;
    +        }
    +
    +        *eq = '\0';
    +
    +        // Trim key
    +        char *key = p;
    +        char *keyEnd = eq - 1;
    +
    +        while (keyEnd >= key && (*keyEnd == ' ' || *keyEnd == '\t')) {
    +            *keyEnd-- = '\0';
    +        }
    +
    +        // Parse r,g,b
    +        const char *val = dvxSkipWs(eq + 1);
    +
    +        int r;
    +        int g;
    +        int b;
    +
    +        if (sscanf(val, "%d,%d,%d", &r, &g, &b) != 3) {
    +            continue;
    +        }
    +
    +        // Find matching color name
    +        for (int32_t i = 0; i < ColorCountE; i++) {
    +            if (strcmp(key, sColorNames[i]) == 0) {
    +                ctx->colorRgb[i][0] = (uint8_t)clampInt(r, 0, UINT8_MAX);
    +                ctx->colorRgb[i][1] = (uint8_t)clampInt(g, 0, UINT8_MAX);
    +                ctx->colorRgb[i][2] = (uint8_t)clampInt(b, 0, UINT8_MAX);
    +                break;
    +            }
    +        }
    +    }
    +
    +    fclose(fp);
    +    dvxApplyColorScheme(ctx);
    +    return true;
    +}
    +
    +
    +// ============================================================
    +// dvxMaximizeWindow
    +// ============================================================
    +
    +void dvxMaximizeWindow(AppContextT *ctx, WindowT *win) {
    +    wmMaximize(&ctx->stack, &ctx->dirty, &ctx->display, win);
    +}
    +
    +
    +// ============================================================
    +// dvxMinimizeWindow
    +// ============================================================
    +
    +void dvxMinimizeWindow(AppContextT *ctx, WindowT *win) {
    +    wmMinimize(&ctx->stack, &ctx->dirty, win);
    +
    +    // wmMinimize only dirties the window's former rect; the icon strip
    +    // must be dirtied too or the new minimized icon never composites
    +    // (matches the sys-menu and minimize-gadget interactive paths).
    +    int32_t iconY;
    +    int32_t iconH;
    +    wmMinimizedIconRect(&ctx->stack, &ctx->display, &iconY, &iconH);
    +    dirtyListAdd(&ctx->dirty, 0, iconY, ctx->display.width, iconH);
    +}
    +
    +
    +// ============================================================
    +// dvxQuit
    +// ============================================================
    +
    +void dvxQuit(AppContextT *ctx) {
    +    ctx->running = false;
    +}
    +
    +
    +void dvxRaiseWindow(AppContextT *ctx, WindowT *win) {
    +    // A window queued for deferred destruction is INERT: raising it would
    +    // refocus it and fire callbacks into torn-down app state.
    +    if (!win || win->destroyPending) {
    +        return;
    +    }
    +
    +    for (int32_t i = 0; i < ctx->stack.count; i++) {
    +        if (ctx->stack.windows[i] == win) {
    +            wmRaiseWindow(&ctx->stack, &ctx->dirty, i);
    +            wmSetFocus(&ctx->stack, &ctx->dirty, ctx->stack.count - 1);
    +            return;
    +        }
    +    }
    +}
    +
    +
    +void dvxResetColorScheme(AppContextT *ctx) {
    +    memcpy(ctx->colorRgb, sDefaultColors, sizeof(sDefaultColors));
    +    dvxApplyColorScheme(ctx);
    +}
    +
    +
    +void dvxResizeWindow(AppContextT *ctx, WindowT *win, int32_t newW, int32_t newH) {
    +    // Dirty old position
    +    dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    +
    +    // Resize
    +    win->w = newW;
    +    win->h = newH;
    +
    +    // Shift position so the window stays fully on screen
    +    if (win->x + newW > ctx->display.width) {
    +        win->x = ctx->display.width - newW;
    +    }
    +
    +    if (win->y + newH > ctx->display.height) {
    +        win->y = ctx->display.height - newH;
    +    }
    +
    +    if (win->x < 0) {
    +        win->x = 0;
    +    }
    +
    +    if (win->y < 0) {
    +        win->y = 0;
    +    }
    +
    +    // Commit the new geometry eagerly (content rect, buffer, onResize) but
    +    // DEFER the repaint: paintNeeded = PAINT_FULL lets the deferred-paint
    +    // flush in dvxUpdate coalesce repeated programmatic resizes (e.g. a
    +    // BASIC form setting Width then Height in one tick) into a single
    +    // paint per frame.  The flush fires onPaint for raw-paint windows too,
    +    // so they are not left blank like the old wgtInvalidate-only path.
    +    // Depth-bracketed: onResize may destroy this window; the bracket
    +    // defers the destroy past the trailing writes so they never touch
    +    // freed memory.
    +    ctx->dispatchDepth++;
    +
    +    wmUpdateContentRect(win);
    +
    +    if (wmReallocContentBuf(win, &ctx->display) == 0) {
    +        if (win->onResize) {
    +            WIN_CALLBACK(ctx, win, win->onResize(win, win->contentW, win->contentH));
    +        }
    +
    +        win->paintNeeded = PAINT_FULL;
    +    }
    +
    +    // Dirty new position
    +    dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    +
    +    ctx->dispatchDepth--;
    +
    +    if (ctx->dispatchDepth == 0 && ctx->pendingDestroyCount > 0) {
    +        flushPendingDestroys(ctx);
    +    }
    +}
    +
    +
    +void *dvxResLoadData(const char *dxePath, const char *resName, uint32_t *outSize) {
    +    if (!dxePath || !resName) {
    +        return NULL;
    +    }
    +
    +    DvxResHandleT *res = dvxResOpen(dxePath);
    +
    +    if (!res) {
    +        return NULL;
    +    }
    +
    +    void *data = dvxResRead(res, resName, outSize);
    +    dvxResClose(res);
    +    return data;
    +}
    +
    +
    +uint8_t *dvxResLoadIcon(AppContextT *ctx, const char *dxePath, const char *resName, int32_t *outW, int32_t *outH, int32_t *outPitch) {
    +    if (!ctx || !dxePath || !resName) {
    +        return NULL;
    +    }
    +
    +    DvxResHandleT *res = dvxResOpen(dxePath);
    +
    +    if (!res) {
    +        return NULL;
    +    }
    +
    +    uint32_t size = 0;
    +    void *buf = dvxResRead(res, resName, &size);
    +    dvxResClose(res);
    +
    +    if (!buf) {
    +        return NULL;
    +    }
    +
    +    uint8_t *pixels = dvxLoadImageFromMemory(ctx, (const uint8_t *)buf, (int32_t)size, outW, outH, outPitch);
    +    free(buf);
    +    return pixels;
    +}
    +
    +
    +bool dvxResLoadText(const char *dxePath, const char *resName, char *buf, int32_t bufSize) {
    +    if (!dxePath || !resName || !buf || bufSize <= 0) {
    +        return false;
    +    }
    +
    +    DvxResHandleT *res = dvxResOpen(dxePath);
    +
    +    if (!res) {
    +        return false;
    +    }
    +
    +    uint32_t size = 0;
    +    void *data = dvxResRead(res, resName, &size);
    +    dvxResClose(res);
    +
    +    if (!data) {
    +        return false;
    +    }
    +
    +    int32_t copyLen = (int32_t)size < bufSize - 1 ? (int32_t)size : bufSize - 1;
    +    memcpy(buf, data, copyLen);
    +    buf[copyLen] = '\0';
    +    free(data);
    +    return true;
    +}
    +
    +
    +void dvxRun(AppContextT *ctx) {
    +    while (dvxUpdate(ctx)) {
    +        // dvxUpdate returns false when the GUI wants to exit
    +    }
    +}
    +
    +
    +// ============================================================
    +// dvxSaveImage
    +// ============================================================
    +//
    +// Save native-format pixel data to a PNG file. Converts from the
    +// display's native pixel format to RGB, then encodes as PNG via
    +// stb_image_write. This is the general-purpose image save function;
    +// dvxScreenshot and dvxWindowScreenshot are convenience wrappers
    +// around it for common use cases.
    +
    +int32_t dvxSaveImage(const AppContextT *ctx, const uint8_t *data, int32_t w, int32_t h, int32_t pitch, const char *path) {
    +    if (!ctx || !data || !path || w <= 0 || h <= 0) {
    +        return -1;
    +    }
    +
    +    uint8_t *rgb = bufferToRgb(&ctx->display, data, w, h, pitch);
    +
    +    if (!rgb) {
    +        return -1;
    +    }
    +
    +    int32_t result = stbi_write_png(path, w, h, RGB_CHANNELS, rgb, w * RGB_CHANNELS) ? 0 : -1;
    +
    +    free(rgb);
    +
    +    return result;
    +}
    +
    +
    +// ============================================================
    +// dvxSaveTheme
    +// ============================================================
    +
    +bool dvxSaveTheme(const AppContextT *ctx, const char *filename) {
    +    FILE *fp = fopen(filename, "wb");
    +
    +    if (!fp) {
    +        return false;
    +    }
    +
    +    fprintf(fp, "; DVX Color Theme\r\n\r\n[colors]\r\n");
    +
    +    for (int32_t i = 0; i < ColorCountE; i++) {
    +        fprintf(fp, "%-20s = %d,%d,%d\r\n", sColorNames[i], ctx->colorRgb[i][0], ctx->colorRgb[i][1], ctx->colorRgb[i][2]);
    +    }
    +
    +    fclose(fp);
    +    return true;
    +}
    +
    +
    +// ============================================================
    +// dvxScreenshot
    +// ============================================================
    +//
    +// Save the entire screen (backbuffer) to a PNG file. Uses the backbuffer
    +// rather than the LFB because reading from video memory through PCI/ISA
    +// is extremely slow on period hardware (uncacheable MMIO reads). The
    +// backbuffer is in system RAM and is always coherent with the LFB since
    +// we only write to the LFB, never read.
    +
    +int32_t dvxScreenshot(AppContextT *ctx, const char *path) {
    +    DisplayT *d = &ctx->display;
    +    int32_t   result;
    +
    +    dvxSetBusy(ctx, true);
    +    result = dvxSaveImage(ctx, d->backBuf, d->width, d->height, d->pitch, path);
    +    dvxSetBusy(ctx, false);
    +
    +    return result;
    +}
    +
    +
    +// ============================================================
    +// dvxSetBusy
    +// ============================================================
    +
    +void dvxSetBusy(AppContextT *ctx, bool busy) {
    +    ctx->busy = busy;
    +
    +    if (busy) {
    +        ctx->cursorId = CURSOR_BUSY;
    +    } else {
    +        ctx->cursorId = CURSOR_ARROW;
    +    }
    +
    +    // dirtyCursorArea covers the worst-case union of all cursor shapes at the
    +    // pointer position, so a single call erases the old shape and exposes the
    +    // new one (both are drawn at the same mouseX/mouseY).
    +    dirtyCursorArea(ctx, ctx->mouseX, ctx->mouseY);
    +
    +    // Flush immediately so the cursor change is visible before
    +    // the blocking operation starts (or after it ends).
    +    // Skip if display isn't initialized yet (startup wallpaper load).
    +    if (ctx->display.backBuf) {
    +        compositeAndFlush(ctx);
    +    }
    +}
    +
    +
    +// ============================================================
    +// dvxSetColor
    +// ============================================================
    +
    +void dvxSetColor(AppContextT *ctx, ColorIdE id, uint8_t r, uint8_t g, uint8_t b) {
    +    if (id < 0 || id >= ColorCountE) {
    +        return;
    +    }
    +
    +    ctx->colorRgb[id][0] = r;
    +    ctx->colorRgb[id][1] = g;
    +    ctx->colorRgb[id][2] = b;
    +
    +    uint32_t packed = packColor(&ctx->display, r, g, b);
    +    *colorSlot(&ctx->colors, id) = packed;
    +
    +    invalidateAllWindows(ctx);
    +}
    +
    +
    +// ============================================================
    +// dvxSetMouseConfig
    +// ============================================================
    +
    +void dvxSetMouseConfig(AppContextT *ctx, int32_t wheelDir, int32_t dblClickMs, int32_t accelThreshold, int32_t mickeyRatio, int32_t wheelStep) {
    +    ctx->wheelDirection = (wheelDir < 0) ? -1 : 1;
    +    ctx->dblClickTicks  = PLATFORM_MS_TO_TICKS(dblClickMs);
    +    sDblClickTicks      = ctx->dblClickTicks;
    +
    +    if (wheelStep < MOUSE_WHEEL_STEP_MIN) {
    +        wheelStep = MOUSE_WHEEL_STEP_MIN;
    +    }
    +
    +    if (wheelStep > MOUSE_WHEEL_STEP_MAX) {
    +        wheelStep = MOUSE_WHEEL_STEP_MAX;
    +    }
    +
    +    ctx->wheelStep = wheelStep;
    +
    +    // Cache the applied values on the context so callers (e.g. the control
    +    // panel snapshot) read live state instead of re-parsing the INI, keeping
    +    // a single source of truth. A non-positive value means "leave unchanged",
    +    // so the previously applied value is preserved.
    +    if (accelThreshold > 0) {
    +        ctx->accelThreshold = accelThreshold;
    +        platformMouseSetAccel(accelThreshold);
    +    }
    +
    +    if (mickeyRatio > 0) {
    +        ctx->mickeyRatio = mickeyRatio;
    +        platformMouseSetMickeys(mickeyRatio, mickeyRatio);
    +    }
    +}
    +
    +
    +// ============================================================
    +// dvxSetTitle
    +// ============================================================
    +
    +void dvxSetTitle(AppContextT *ctx, WindowT *win, const char *title) {
    +    wmSetTitle(win, &ctx->dirty, title);
    +
    +    if (ctx->onTitleChange) {
    +        ctx->onTitleChange(ctx->titleChangeCtx);
    +    }
    +}
    +
    +
    +// ============================================================
    +// dvxSetWallpaper
    +// ============================================================
    +
    +bool dvxSetWallpaper(AppContextT *ctx, const char *path) {
    +    // buildWallpaperBuf pumps dvxUpdate to keep the UI alive, and a timer
    +    // or idle callback fired from there may call back in here. A nested
    +    // call would free the buffer being built and clear busy early, so
    +    // reject it outright.
    +    static bool sBuilding = false;
    +
    +    if (sBuilding) {
    +        return false;
    +    }
    +
    +    free(ctx->wallpaperBuf);
    +    ctx->wallpaperBuf   = NULL;
    +    ctx->wallpaperPitch = 0;
    +
    +    if (!path) {
    +        ctx->wallpaperPath[0] = '\0';
    +        dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    +        return true;
    +    }
    +
    +    // dvxSetWallpaperMode and dvxChangeVideoMode reload by passing the
    +    // stored path itself; copying it onto itself is undefined.
    +    if (path != ctx->wallpaperPath) {
    +        strncpy(ctx->wallpaperPath, path, sizeof(ctx->wallpaperPath) - 1);
    +        ctx->wallpaperPath[sizeof(ctx->wallpaperPath) - 1] = '\0';
    +    }
    +
    +    dvxSetBusy(ctx, true);
    +
    +    int imgW;
    +    int imgH;
    +    int channels;
    +    uint8_t *rgb = stbi_load(path, &imgW, &imgH, &channels, 3);
    +
    +    if (!rgb) {
    +        dvxSetBusy(ctx, false);
    +        return false;
    +    }
    +
    +    int32_t pitch = ctx->display.width * ctx->display.format.bytesPerPixel;
    +
    +    sBuilding           = true;
    +    ctx->wallpaperBuf   = buildWallpaperBuf(ctx, rgb, imgW, imgH, ctx->wallpaperMode);
    +    ctx->wallpaperPitch = pitch;
    +    sBuilding           = false;
    +    dvxSetBusy(ctx, false);
    +
    +    stbi_image_free(rgb);
    +
    +    if (!ctx->wallpaperBuf) {
    +        return false;
    +    }
    +
    +    dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    +    return true;
    +}
    +
    +
    +// ============================================================
    +// dvxSetWallpaperMode
    +// ============================================================
    +
    +void dvxSetWallpaperMode(AppContextT *ctx, WallpaperModeE mode) {
    +    ctx->wallpaperMode = mode;
    +
    +    if (ctx->wallpaperPath[0]) {
    +        dvxSetWallpaper(ctx, ctx->wallpaperPath);
    +    }
    +}
    +
    +
    +// ============================================================
    +// dvxSetWindowIcon
    +// ============================================================
    +
    +int32_t dvxSetWindowIcon(AppContextT *ctx, WindowT *win, const char *path) {
    +    return wmSetIcon(win, path, &ctx->display);
    +}
    +
    +
    +// ============================================================
    +// openContextMenu  -- open a context menu at a screen position
    +// ============================================================
    +//
    +// Context menus reuse the same popup system as menu bar popups but with
    +// isContextMenu=true. The difference affects dismiss behavior: context
    +// menus close on any click outside (since there's no menu bar to switch
    +// to), while menu bar popups allow horizontal mouse movement to switch
    +// between top-level menus. Position is clamped to screen edges so the
    +// popup doesn't go off-screen.
    +
    +void dvxShowContextMenu(AppContextT *ctx, WindowT *win, MenuT *menu, int32_t screenX, int32_t screenY) {
    +    openContextMenu(ctx, win, menu, screenX, screenY);
    +}
    +
    +
    +void dvxShowWindow(AppContextT *ctx, WindowT *win) {
    +    // A window queued for deferred destruction is INERT: re-showing it
    +    // would let hit-testing and focus fire callbacks into app state that
    +    // may already be torn down.
    +    if (!win || win->visible || win->destroyPending) {
    +        return;
    +    }
    +
    +    win->visible = true;
    +    dvxInvalidateWindow(ctx, win);
    +}
    +
    +
    +void dvxShutdown(AppContextT *ctx) {
    +    platformKeyUpShutdown();
    +
    +    // Destroy all remaining windows
    +    while (ctx->stack.count > 0) {
    +        wmDestroyWindow(&ctx->stack, ctx->stack.windows[ctx->stack.count - 1]);
    +    }
    +
    +    free(ctx->stack.windows);
    +    ctx->stack.windows = NULL;
    +    ctx->stack.count   = 0;
    +    ctx->stack.cap     = 0;
    +
    +    free(ctx->popup.parentStack);
    +    ctx->popup.parentStack = NULL;
    +    ctx->popup.parentCap   = 0;
    +
    +    free(ctx->dirty.rects);
    +    ctx->dirty.rects = NULL;
    +    ctx->dirty.count = 0;
    +    ctx->dirty.cap   = 0;
    +
    +    free(ctx->wallpaperBuf);
    +    ctx->wallpaperBuf = NULL;
    +    arrfree(ctx->videoModes);
    +    ctx->videoModes = NULL;
    +    videoShutdown(&ctx->display);
    +}
    +
    +
    +uint32_t dvxTextHash(const char *text) {
    +    if (!text) {
    +        return 0;
    +    }
    +
    +    uint32_t h = 5381;
    +
    +    while (*text) {
    +        h = ((h << 5) + h) ^ (uint8_t)*text;
    +        text++;
    +    }
    +
    +    return h;
    +}
    +
    +
    +// Tile windows in a grid. The grid dimensions are chosen so columns =
    +// ceil(sqrt(n)), which produces a roughly square grid. This is better than
    +// always using rows or columns because it maximizes the minimum dimension
    +// of each tile (a 1xN or Nx1 layout makes windows very narrow or short).
    +// The last row may have fewer windows; those get wider tiles to fill the
    +// remaining screen width, avoiding dead space.
    +//
    +// The integer sqrt is computed by a simple loop rather than calling sqrt()
    +// to avoid pulling in floating-point math on DJGPP targets.
    +
    +void dvxTileWindows(AppContextT *ctx) {
    +    int32_t screenW = ctx->display.width;
    +    int32_t screenH = ctx->display.height;
    +    int32_t count   = countVisibleWindows(ctx);
    +
    +    if (count == 0) {
    +        return;
    +    }
    +
    +    // Integer ceil(sqrt(count)) for column count
    +    int32_t cols = 1;
    +
    +    while (cols * cols < count) {
    +        cols++;
    +    }
    +
    +    int32_t rows = (count + cols - 1) / cols;
    +    int32_t tileW = screenW / cols;
    +    int32_t tileH = screenH / rows;
    +
    +    if (tileW < MIN_WINDOW_W) {
    +        tileW = MIN_WINDOW_W;
    +    }
    +
    +    if (tileH < MIN_WINDOW_H) {
    +        tileH = MIN_WINDOW_H;
    +    }
    +
    +    int32_t slot = 0;
    +
    +    for (int32_t i = 0; i < ctx->stack.count; i++) {
    +        WindowT *win = ctx->stack.windows[i];
    +
    +        if (win->minimized || !win->visible) {
    +            continue;
    +        }
    +
    +        int32_t row = slot / cols;
    +        int32_t col = slot % cols;
    +
    +        // Last row: fewer windows get wider tiles
    +        int32_t remaining = count - row * cols;
    +        int32_t rowCols   = (remaining < cols) ? remaining : cols;
    +        int32_t cellW     = screenW / rowCols;
    +
    +        repositionWindow(ctx, win, col * cellW, row * tileH, cellW, tileH);
    +
    +        slot++;
    +    }
    +}
    +
    +
    +// ============================================================
    +// dvxTileWindowsH
    +// ============================================================
    +//
    +// Horizontal tiling: windows side by side left to right, each the full
    +// screen height. Good for comparing two documents or viewing output
    +// alongside source. With many windows the tiles become very narrow, but
    +// MIN_WINDOW_W prevents them from becoming unusably small.
    +
    +void dvxTileWindowsH(AppContextT *ctx) {
    +    int32_t screenW = ctx->display.width;
    +    int32_t screenH = ctx->display.height;
    +    int32_t count   = countVisibleWindows(ctx);
    +
    +    if (count == 0) {
    +        return;
    +    }
    +
    +    int32_t tileW = screenW / count;
    +
    +    if (tileW < MIN_WINDOW_W) {
    +        tileW = MIN_WINDOW_W;
    +    }
    +
    +    int32_t slot = 0;
    +
    +    for (int32_t i = 0; i < ctx->stack.count; i++) {
    +        WindowT *win = ctx->stack.windows[i];
    +
    +        if (win->minimized || !win->visible) {
    +            continue;
    +        }
    +
    +        repositionWindow(ctx, win, slot * tileW, 0, tileW, screenH);
    +
    +        slot++;
    +    }
    +}
    +
    +
    +// ============================================================
    +// dvxTileWindowsV
    +// ============================================================
    +//
    +// Vertical tiling: windows stacked top to bottom, each the full screen
    +// width. The complement of dvxTileWindowsH.
    +
    +void dvxTileWindowsV(AppContextT *ctx) {
    +    int32_t screenW = ctx->display.width;
    +    int32_t screenH = ctx->display.height;
    +    int32_t count   = countVisibleWindows(ctx);
    +
    +    if (count == 0) {
    +        return;
    +    }
    +
    +    int32_t tileH = screenH / count;
    +
    +    if (tileH < MIN_WINDOW_H) {
    +        tileH = MIN_WINDOW_H;
    +    }
    +
    +    int32_t slot = 0;
    +
    +    for (int32_t i = 0; i < ctx->stack.count; i++) {
    +        WindowT *win = ctx->stack.windows[i];
    +
    +        if (win->minimized || !win->visible) {
    +            continue;
    +        }
    +
    +        repositionWindow(ctx, win, 0, slot * tileH, screenW, tileH);
    +
    +        slot++;
    +    }
    +}
    +
    +
    +// ============================================================
    +// dvxUpdate
    +// ============================================================
    +//
    +// Single iteration of the main event loop. This is the fundamental
    +// heartbeat of the GUI. The sequence is:
    +//   1. Poll hardware (mouse position/buttons, keyboard buffer)
    +//   2. Dispatch events (route input to windows, menus, widgets)
    +//   3. Update tooltip visibility
    +//   4. Poll ANSI terminal widgets (check for new data from PTYs)
    +//   5. Periodic tasks (minimized icon thumbnail refresh)
    +//   6. Composite dirty regions and flush to LFB
    +//   7. If nothing was dirty: run idle callback or yield CPU
    +//
    +// The idle callback mechanism exists so applications can do background
    +// work (e.g., polling serial ports, processing network data) when the
    +// GUI has nothing to paint. Without it, the loop would busy-wait or
    +// yield the CPU slice. With it, the application gets a callback to do
    +// useful work. platformYield is the fallback  -- it calls INT 28h (DOS
    +// idle) or SDL_Delay (Linux) to avoid burning CPU when truly idle.
    +
    +bool dvxUpdate(AppContextT *ctx) {
    +    if (!ctx->running) {
    +        return false;
    +    }
    +
    +    // Everything inside this depth-tracked section may run user
    +    // callbacks.  dvxDestroyWindow defers actual teardown while
    +    // dispatchDepth > 0 (see deferDestroyWindow); nested dvxUpdate
    +    // calls from modal dialog loops stack the depth, so the flush only
    +    // happens once every callback frame has unwound.
    +    ctx->dispatchDepth++;
    +
    +    pollMouse(ctx);
    +    pollKeyboard(ctx);
    +    dispatchEvents(ctx);
    +    updateTooltip(ctx);
    +    pollWidgets(ctx);
    +    if (sCursorBlinkFn) {
    +        sCursorBlinkFn();
    +    }
    +
    +    wgtUpdateTimers();
    +
    +    // Flush deferred paints. paintNeeded is set by wgtInvalidatePaint
    +    // (PARTIAL) or wgtInvalidate (FULL). Multiple calls per frame are
    +    // batched into one paint -- the highest level wins.  This loop runs
    +    // INSIDE the depth-tracked section so an onPaint that destroys its
    +    // own window defers: the paintNeeded/icon writes below then land on
    +    // live memory and the stack is never compacted mid-iteration.
    +    // Deferred-destroyed windows are skipped -- their app state may
    +    // already be torn down (this also covers nested dvxUpdate loops,
    +    // where the flush below is suppressed).
    +    for (int32_t i = 0; i < ctx->stack.count; i++) {
    +        WindowT *win = ctx->stack.windows[i];
    +
    +        if (win->destroyPending) {
    +            continue;
    +        }
    +
    +        if (win->paintNeeded && win->onPaint) {
    +            RectT fullRect = {0, 0, win->contentW, win->contentH};
    +            WIN_CALLBACK(ctx, win, win->onPaint(win, &fullRect));
    +            // widgetOnPaint clears paintNeeded for widget windows.
    +            // For raw-paint windows, clear it here.
    +            win->paintNeeded      = PAINT_NONE;
    +            win->iconNeedsRefresh = true;
    +            dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    +        }
    +    }
    +
    +    ctx->dispatchDepth--;
    +
    +    if (ctx->dispatchDepth == 0 && ctx->pendingDestroyCount > 0) {
    +        flushPendingDestroys(ctx);
    +    }
    +
    +    ctx->frameCount++;
    +
    +    if (ctx->frameCount % ICON_REFRESH_INTERVAL == 0) {
    +        refreshMinimizedIcons(ctx);
    +    }
    +
    +    if (ctx->dirty.count > 0) {
    +        compositeAndFlush(ctx);
    +    } else if (ctx->idleCallback) {
    +        ctx->idleCallback(ctx->idleCtx);
    +    } else {
    +        platformYield();
    +    }
    +
    +    // Release key-pressed button after one frame. The button was set to
    +    // "pressed" state in dispatchAccelKey; here we clear it and fire
    +    // onClick. The one-frame delay ensures the pressed visual state
    +    // renders before the callback runs (which may open a dialog, etc.).
    +    if (sKeyPressedBtn) {
    +        if (wclsHas(sKeyPressedBtn, WGT_METHOD_ON_DRAG_END)) {
    +            // Pass button center as coordinates so bounds check succeeds and onClick fires
    +            WidgetT *root = sKeyPressedBtn->window ? sKeyPressedBtn->window->widgetRoot : sKeyPressedBtn;
    +            wclsOnDragEnd(sKeyPressedBtn, root,
    +                          sKeyPressedBtn->x + sKeyPressedBtn->w / 2,
    +                          sKeyPressedBtn->y + sKeyPressedBtn->h / 2);
    +        }
    +
    +        wgtInvalidatePaint(sKeyPressedBtn);
    +        sKeyPressedBtn = NULL;
    +    }
    +
    +    ctx->prevMouseX       = ctx->mouseX;
    +    ctx->prevMouseY       = ctx->mouseY;
    +    ctx->prevMouseButtons = ctx->mouseButtons;
    +
    +    return ctx->running;
    +}
    +
    +
    +// ============================================================
    +// dvxWindowScreenshot
    +// ============================================================
    +//
    +// Save a window's content buffer to a PNG file. Because each window has
    +// its own persistent content buffer (not a shared backbuffer), this
    +// captures the full content even if the window is partially or fully
    +// occluded by other windows. This is a unique advantage of the per-window
    +// content buffer architecture.
    +
    +int32_t dvxWindowScreenshot(AppContextT *ctx, WindowT *win, const char *path) {
    +    int32_t result;
    +
    +    if (!win || !win->contentBuf) {
    +        return -1;
    +    }
    +
    +    dvxSetBusy(ctx, true);
    +    result = dvxSaveImage(ctx, win->contentBuf, win->contentW, win->contentH, win->contentPitch, path);
    +    dvxSetBusy(ctx, false);
    +
    +    return result;
    +}
    +
    +
     // ============================================================
     // enumModeCb  -- used during dvxInit to capture available modes
     // ============================================================
    @@ -2262,7 +3981,7 @@ static void handleMouseButton(AppContextT *ctx, int32_t mx, int32_t my, int32_t
     
         if (iconIdx >= 0) {
             WindowT *iconWin = ctx->stack.windows[iconIdx];
    -        clock_t  now     = clock();
    +        PlatformTicksT now = platformClock();
     
             if (ctx->lastIconClickId == iconWin->id &&
                 (now - ctx->lastIconClickTime) < ctx->dblClickTicks) {
    @@ -2316,7 +4035,7 @@ static void handleMouseButton(AppContextT *ctx, int32_t mx, int32_t my, int32_t
     
             case HIT_TITLE:
                 {
    -                clock_t now = clock();
    +                PlatformTicksT now = platformClock();
     
                     if (win->resizable &&
                         ctx->lastTitleClickId == win->id &&
    @@ -2339,7 +4058,7 @@ static void handleMouseButton(AppContextT *ctx, int32_t mx, int32_t my, int32_t
     
             case HIT_CLOSE:
                 {
    -                clock_t now = clock();
    +                PlatformTicksT now = platformClock();
     
                     if (ctx->lastCloseClickId == win->id &&
                         (now - ctx->lastCloseClickTime) < ctx->dblClickTicks) {
    @@ -2526,22 +4245,6 @@ static int32_t nextMenuItem(const bool *firstSeparator, size_t stride, int32_t c
     }
     
     
    -// ============================================================
    -// openContextMenu  -- open a context menu at a screen position
    -// ============================================================
    -//
    -// Context menus reuse the same popup system as menu bar popups but with
    -// isContextMenu=true. The difference affects dismiss behavior: context
    -// menus close on any click outside (since there's no menu bar to switch
    -// to), while menu bar popups allow horizontal mouse movement to switch
    -// between top-level menus. Position is clamped to screen edges so the
    -// popup doesn't go off-screen.
    -
    -void dvxShowContextMenu(AppContextT *ctx, WindowT *win, MenuT *menu, int32_t screenX, int32_t screenY) {
    -    openContextMenu(ctx, win, menu, screenX, screenY);
    -}
    -
    -
     static void openContextMenu(AppContextT *ctx, WindowT *win, MenuT *menu, int32_t screenX, int32_t screenY) {
         if (!menu || menu->itemCount <= 0) {
             return;
    @@ -2752,6 +4455,19 @@ static void openSysMenu(AppContextT *ctx, WindowT *win) {
     }
     
     
    +// Packs the scheme's RGB source values into the display's native pixel
    +// format without repainting.  dvxApplyColorScheme adds the repaint;
    +// dvxChangeVideoMode repacks before the content buffers exist for the
    +// new format and repaints later.
    +static void packColorScheme(AppContextT *ctx) {
    +    DisplayT *d = &ctx->display;
    +
    +    for (int32_t i = 0; i < ColorCountE; i++) {
    +        *colorSlot(&ctx->colors, (ColorIdE)i) = packColor(d, ctx->colorRgb[i][0], ctx->colorRgb[i][1], ctx->colorRgb[i][2]);
    +    }
    +}
    +
    +
     // ============================================================
     // pollKeyboard
     // ============================================================
    @@ -3298,11 +5014,13 @@ static void pollKeyboard(AppContextT *ctx) {
                             // BASIC Change handler unloading its form) -- next
                             // is dangling then and must not be dereferenced.
                             if (widgetTransferFocus(next)) {
    -                            // Scroll the widget into view if needed
    -                            int32_t scrollX = win->hScroll ? win->hScroll->value : 0;
    -                            int32_t scrollY = win->vScroll ? win->vScroll->value : 0;
    -                            int32_t virtX   = next->x + scrollX;
    -                            int32_t virtY   = next->y + scrollY;
    +                            // Scroll the widget into view if needed.
    +                            // Widget coordinates are stored in virtual
    +                            // (unscrolled) content space; only the root is
    +                            // offset by the scroll value, so the widget's
    +                            // own x/y already are the virtual position.
    +                            int32_t virtX = next->x;
    +                            int32_t virtY = next->y;
     
                                 if (win->vScroll) {
                                     if (virtY < win->vScroll->value) {
    @@ -3457,8 +5175,6 @@ static void pollWidgets(AppContextT *ctx) {
     }
     
     
    -
    -
     // ============================================================
     // popupHandleOutsideCurrent
     // ============================================================
    @@ -3793,10 +5509,10 @@ static void updateCursorShape(AppContextT *ctx) {
     // individual tooltips even though the toolbar itself handles hit testing.
     
     static void updateTooltip(AppContextT *ctx) {
    -    clock_t now       = clock();
    -    clock_t threshold = (clock_t)TOOLTIP_DELAY_MS * CLOCKS_PER_SEC / 1000;
    -    int32_t mx        = ctx->mouseX;
    -    int32_t my        = ctx->mouseY;
    +    PlatformTicksT now       = platformClock();
    +    PlatformTicksT threshold = PLATFORM_MS_TO_TICKS(TOOLTIP_DELAY_MS);
    +    int32_t        mx        = ctx->mouseX;
    +    int32_t        my        = ctx->mouseY;
     
         // Mouse moved or button pressed  -- hide tooltip and reset timer
         if (mx != ctx->prevMouseX || my != ctx->prevMouseY || ctx->mouseButtons) {
    @@ -3929,1681 +5645,3 @@ static void updateTooltip(AppContextT *ctx) {
         dirtyListAdd(&ctx->dirty, ctx->tooltipX, ctx->tooltipY, tw, th);
     }
     
    -
    -// ============================================================
    -// dvxAddAccel
    -// ============================================================
    -//
    -// Accelerator entries are pre-normalized at registration time: the key
    -// is uppercased and modifier bits are masked to just Ctrl|Alt. This
    -// moves the normalization cost from the hot path (every keypress) to
    -// the cold path (one-time setup), so checkAccelTable can do a simple
    -// integer compare per entry.
    -
    -void dvxAddAccel(AccelTableT *table, int32_t key, int32_t modifiers, int32_t cmdId) {
    -    if (!table) {
    -        return;
    -    }
    -
    -    if (table->count >= table->cap) {
    -        int32_t newCap = table->cap ? table->cap * 2 : 8;
    -        AccelEntryT *newBuf = (AccelEntryT *)realloc(table->entries, newCap * sizeof(AccelEntryT));
    -
    -        if (!newBuf) {
    -            return;
    -        }
    -
    -        table->entries = newBuf;
    -        table->cap     = newCap;
    -    }
    -
    -    int32_t normKey = key;
    -
    -    if (normKey >= 'a' && normKey <= 'z') {
    -        normKey = normKey - 32;
    -    }
    -
    -    AccelEntryT *e = &table->entries[table->count++];
    -    e->key       = key;
    -    e->modifiers = modifiers;
    -    e->cmdId     = cmdId;
    -    e->normKey   = normKey;
    -    e->normMods  = modifiers & (ACCEL_CTRL | ACCEL_ALT);
    -}
    -
    -
    -// ============================================================
    -// dvxApplyColorScheme
    -// ============================================================
    -
    -void dvxApplyColorScheme(AppContextT *ctx) {
    -    DisplayT *d = &ctx->display;
    -
    -    for (int32_t i = 0; i < ColorCountE; i++) {
    -        *colorSlot(&ctx->colors, (ColorIdE)i) = packColor(d, ctx->colorRgb[i][0], ctx->colorRgb[i][1], ctx->colorRgb[i][2]);
    -    }
    -
    -    invalidateAllWindows(ctx);
    -}
    -
    -
    -// ============================================================
    -// dvxCascadeWindows
    -// ============================================================
    -//
    -// Arranges windows in the classic cascade pattern: each window is the same
    -// size (2/3 of screen), offset diagonally by the title bar height so each
    -// title bar remains visible. When the cascade would go off-screen, it wraps
    -// back to (0,0). This matches the Windows 3.x cascade behavior.
    -// The step size is title_height + border_width so exactly one title bar's
    -// worth of the previous window peeks out above and to the left.
    -
    -void dvxCascadeWindows(AppContextT *ctx) {
    -    int32_t screenW = ctx->display.width;
    -    int32_t screenH = ctx->display.height;
    -    int32_t offsetX = 0;
    -    int32_t offsetY = 0;
    -    int32_t step    = CHROME_TITLEBAR_BOTTOM;
    -
    -    int32_t winW = screenW * CASCADE_SIZE_NUMER / CASCADE_SIZE_DENOM;
    -    int32_t winH = screenH * CASCADE_SIZE_NUMER / CASCADE_SIZE_DENOM;
    -
    -    if (winW < MIN_WINDOW_W) {
    -        winW = MIN_WINDOW_W;
    -    }
    -
    -    if (winH < MIN_WINDOW_H) {
    -        winH = MIN_WINDOW_H;
    -    }
    -
    -    for (int32_t i = 0; i < ctx->stack.count; i++) {
    -        WindowT *win = ctx->stack.windows[i];
    -
    -        if (win->minimized || !win->visible) {
    -            continue;
    -        }
    -
    -        repositionWindow(ctx, win, offsetX, offsetY, winW, winH);
    -
    -        offsetX += step;
    -        offsetY += step;
    -
    -        // Wrap around if we'd go off screen
    -        if (offsetX + winW > screenW || offsetY + winH > screenH) {
    -            offsetX = 0;
    -            offsetY = 0;
    -        }
    -    }
    -}
    -
    -
    -// ============================================================
    -// dvxChangeVideoMode
    -// ============================================================
    -//
    -// Live video mode switch.  Saves the old display state, attempts to
    -// set the new mode, and if successful, reinitializes all dependent
    -// subsystems: blit ops, colors, cursors, mouse range, wallpaper,
    -// and all window content buffers.  Windows larger than the new
    -// screen are clamped.  On failure, the old mode is restored.
    -
    -int32_t dvxChangeVideoMode(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
    -    // Save old state for rollback
    -    DisplayT oldDisplay = ctx->display;
    -
    -    // Stash old wallpaper (don't free  -- we may need it for rollback)
    -    uint8_t *oldWpBuf   = ctx->wallpaperBuf;
    -    int32_t  oldWpPitch = ctx->wallpaperPitch;
    -    ctx->wallpaperBuf   = NULL;
    -    ctx->wallpaperPitch = 0;
    -
    -    // Free old video buffers (no text mode restore)
    -    platformVideoFreeBuffers(&ctx->display);
    -
    -    // Try the new mode
    -    if (videoInit(&ctx->display, requestedW, requestedH, preferredBpp) != 0) {
    -        // Restore old mode
    -        ctx->display = oldDisplay;
    -        ctx->display.backBuf = NULL;
    -        ctx->display.palette = NULL;
    -
    -        if (videoInit(&ctx->display, oldDisplay.width, oldDisplay.height, oldDisplay.format.bitsPerPixel) != 0) {
    -            // Both failed  -- catastrophic
    -            return -1;
    -        }
    -
    -        // Restore wallpaper
    -        ctx->wallpaperBuf   = oldWpBuf;
    -        ctx->wallpaperPitch = oldWpPitch;
    -        drawInit(&ctx->blitOps, &ctx->display);
    -        dvxApplyColorScheme(ctx);
    -        return -1;
    -    }
    -
    -    // New mode succeeded  -- free old wallpaper buffer
    -    free(oldWpBuf);
    -
    -    // Reinit blit ops for new pixel format
    -    drawInit(&ctx->blitOps, &ctx->display);
    -
    -    // Repack all colors for new pixel format
    -    dvxApplyColorScheme(ctx);
    -
    -    // Reinit mouse range
    -    platformMouseInit(ctx->display.width, ctx->display.height);
    -    ctx->hasMouseWheel = platformMouseWheelInit();
    -
    -    // Clamp mouse position to new screen
    -    if (ctx->mouseX >= ctx->display.width) {
    -        ctx->mouseX = ctx->display.width - 1;
    -    }
    -
    -    if (ctx->mouseY >= ctx->display.height) {
    -        ctx->mouseY = ctx->display.height - 1;
    -    }
    -
    -    // Clamp and reallocate all window content buffers
    -    for (int32_t i = 0; i < ctx->stack.count; i++) {
    -        WindowT *win = ctx->stack.windows[i];
    -
    -        // Clamp window position to new screen bounds
    -        if (win->x + win->w > ctx->display.width) {
    -            win->x = ctx->display.width - win->w;
    -        }
    -
    -        if (win->x < 0) {
    -            win->x = 0;
    -            win->w = ctx->display.width;
    -        }
    -
    -        if (win->y + win->h > ctx->display.height) {
    -            win->y = ctx->display.height - win->h;
    -        }
    -
    -        if (win->y < 0) {
    -            win->y = 0;
    -            win->h = ctx->display.height;
    -        }
    -
    -        // Clear maximized flag since screen size changed
    -        win->maximized = false;
    -
    -        WIN_CALLBACK(ctx, win, wmResizeCommit(win, &ctx->display));
    -    }
    -
    -    // Reload wallpaper at the new resolution/bpp
    -    if (ctx->wallpaperPath[0]) {
    -        dvxSetWallpaper(ctx, ctx->wallpaperPath);
    -    }
    -
    -    // Reset clip and dirty the full screen
    -    resetClipRect(&ctx->display);
    -    dirtyListInit(&ctx->dirty);
    -    dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    -
    -    return 0;
    -}
    -
    -
    -// ============================================================
    -// dvxClipboardCopy / dvxClipboardGet
    -// ============================================================
    -//
    -// The clipboard is a simple in-process text buffer managed by the
    -// platform layer (clipboardCopy/clipboardGet). There is no inter-process
    -// clipboard because DVX runs as a single-process windowing system  -- all
    -// windows share the same address space. The thin wrappers here exist to
    -// keep the platform layer out of application code's include path.
    -
    -void dvxClipboardCopy(const char *text, int32_t len) {
    -    clipboardCopy(text, len);
    -}
    -
    -
    -const char *dvxClipboardGet(int32_t *outLen) {
    -    return clipboardGet(outLen);
    -}
    -
    -
    -// ============================================================
    -// dvxColorLabel
    -// ============================================================
    -
    -const char *dvxColorLabel(ColorIdE id) {
    -    if (id < 0 || id >= ColorCountE) {
    -        return "Unknown";
    -    }
    -
    -    return sColorLabels[id];
    -}
    -
    -
    -// ============================================================
    -// dvxColorName
    -// ============================================================
    -
    -const char *dvxColorName(ColorIdE id) {
    -    if (id < 0 || id >= ColorCountE) {
    -        return "unknown";
    -    }
    -
    -    return sColorNames[id];
    -}
    -
    -
    -// ============================================================
    -// dvxCreateAccelTable
    -// ============================================================
    -
    -AccelTableT *dvxCreateAccelTable(void) {
    -    AccelTableT *table = (AccelTableT *)calloc(1, sizeof(AccelTableT));
    -    return table;
    -}
    -
    -
    -// ============================================================
    -// dvxCreateWindow
    -// ============================================================
    -
    -WindowT *dvxCreateWindow(AppContextT *ctx, const char *title, int32_t x, int32_t y, int32_t w, int32_t h, bool resizable) {
    -    // Auto-cascade: if another window already occupies this exact position,
    -    // offset diagonally by the title bar height so the new window doesn't
    -    // sit directly on top.  Keeps offsetting while collisions exist, wrapping
    -    // back to the origin if we'd go off screen.
    -    int32_t step = CHROME_TITLEBAR_BOTTOM;
    -
    -    for (;;) {
    -        bool collision = false;
    -
    -        for (int32_t i = 0; i < ctx->stack.count; i++) {
    -            WindowT *other = ctx->stack.windows[i];
    -
    -            if (other->x == x && other->y == y) {
    -                collision = true;
    -                break;
    -            }
    -        }
    -
    -        if (!collision) {
    -            break;
    -        }
    -
    -        x += step;
    -        y += step;
    -
    -        if (x + w > ctx->display.width || y + h > ctx->display.height) {
    -            x = step;
    -            y = step;
    -            break;
    -        }
    -    }
    -
    -    // Clamp to screen so the window is fully visible
    -    if (x + w > ctx->display.width) {
    -        x = ctx->display.width - w;
    -    }
    -
    -    if (y + h > ctx->display.height) {
    -        y = ctx->display.height - h;
    -    }
    -
    -    if (x < 0) {
    -        x = 0;
    -    }
    -
    -    if (y < 0) {
    -        y = 0;
    -    }
    -
    -    WindowT *win = wmCreateWindow(&ctx->stack, &ctx->display, title, x, y, w, h, resizable);
    -
    -    if (win) {
    -        // Stamp window with the current app ID (set by the shell before
    -        // calling app entry points). Enables per-app window tracking for
    -        // cleanup on crash/termination.
    -        win->appId = ctx->currentAppId;
    -
    -        // Raise and focus
    -        int32_t idx = ctx->stack.count - 1;
    -        wmSetFocus(&ctx->stack, &ctx->dirty, idx);
    -        dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    -    }
    -
    -    return win;
    -}
    -
    -
    -// ============================================================
    -// dvxCreateWindowCentered
    -// ============================================================
    -
    -WindowT *dvxCreateWindowCentered(AppContextT *ctx, const char *title, int32_t w, int32_t h, bool resizable) {
    -    int32_t x = (ctx->display.width - w) / 2;
    -    int32_t y = (ctx->display.height - h) / 2;
    -
    -    return dvxCreateWindow(ctx, title, x, y, w, h, resizable);
    -}
    -
    -
    -void dvxDestroyWindow(AppContextT *ctx, WindowT *win) {
    -    destroyWindowPrep(ctx, win);
    -
    -    // A user callback is on the stack: defer the teardown so the
    -    // dispatch code's widget pointers stay valid until it unwinds.
    -    if (ctx->dispatchDepth > 0) {
    -        deferDestroyWindow(ctx, win);
    -        return;
    -    }
    -
    -    destroyWindowSync(ctx, win);
    -}
    -
    -
    -void dvxDestroyWindowNow(AppContextT *ctx, WindowT *win) {
    -    destroyWindowPrep(ctx, win);
    -
    -    // A previously deferred window is destroyed here and now; drop its
    -    // queued state so the flush hint stays balanced.
    -    if (win->destroyPending) {
    -        win->destroyPending = false;
    -
    -        if (ctx->pendingDestroyCount > 0) {
    -            ctx->pendingDestroyCount--;
    -        }
    -    }
    -
    -    destroyWindowSync(ctx, win);
    -}
    -
    -
    -// Resizes a window to exactly fit its widget tree's minimum size,
    -// accounting for chrome overhead (title bar, borders, optional menu bar).
    -// Used after building a dialog's widget tree to size the dialog
    -// automatically rather than requiring the caller to compute sizes manually.
    -
    -void dvxFitWindow(AppContextT *ctx, WindowT *win) {
    -    if (!ctx || !win || !win->widgetRoot) {
    -        return;
    -    }
    -
    -    widgetCalcMinSizeTree(win->widgetRoot, &ctx->font);
    -
    -    int32_t topChrome = CHROME_TOTAL_TOP;
    -
    -    if (win->menuBar) {
    -        topChrome += CHROME_MENU_HEIGHT;
    -    }
    -
    -    int32_t newW = win->widgetRoot->calcMinW + CHROME_TOTAL_SIDE * 2;
    -    int32_t newH = win->widgetRoot->calcMinH + topChrome + CHROME_TOTAL_BOTTOM;
    -
    -    // Ensure the window is at least as wide/tall as the WM minimum
    -    // (accounts for menu bar width, title bar gadgets, etc.)
    -    int32_t wmMinW;
    -    int32_t wmMinH;
    -    wmMinWindowSize(win, &wmMinW, &wmMinH);
    -
    -    if (newW < wmMinW) {
    -        newW = wmMinW;
    -    }
    -
    -    if (newH < wmMinH) {
    -        newH = wmMinH;
    -    }
    -
    -    dvxResizeWindow(ctx, win, newW, newH);
    -}
    -
    -
    -void dvxFitWindowW(AppContextT *ctx, WindowT *win) {
    -    if (!ctx || !win || !win->widgetRoot) {
    -        return;
    -    }
    -
    -    widgetCalcMinSizeTree(win->widgetRoot, &ctx->font);
    -
    -    int32_t newW = win->widgetRoot->calcMinW + CHROME_TOTAL_SIDE * 2;
    -
    -    dvxResizeWindow(ctx, win, newW, win->h);
    -}
    -
    -
    -void dvxFitWindowH(AppContextT *ctx, WindowT *win) {
    -    if (!ctx || !win || !win->widgetRoot) {
    -        return;
    -    }
    -
    -    widgetCalcMinSizeTree(win->widgetRoot, &ctx->font);
    -
    -    int32_t topChrome = CHROME_TOTAL_TOP;
    -
    -    if (win->menuBar) {
    -        topChrome += CHROME_MENU_HEIGHT;
    -    }
    -
    -    int32_t newH = win->widgetRoot->calcMinH + topChrome + CHROME_TOTAL_BOTTOM;
    -
    -    dvxResizeWindow(ctx, win, win->w, newH);
    -}
    -
    -
    -// ============================================================
    -// dvxFreeAccelTable
    -// ============================================================
    -
    -void dvxFreeAccelTable(AccelTableT *table) {
    -    if (table) {
    -        free(table->entries);
    -    }
    -
    -    free(table);
    -}
    -
    -
    -// ============================================================
    -// dvxFreeImage
    -// ============================================================
    -
    -void dvxFreeImage(uint8_t *data) {
    -    free(data);
    -}
    -
    -
    -const BlitOpsT *dvxGetBlitOps(const AppContextT *ctx) {
    -    return &ctx->blitOps;
    -}
    -
    -
    -// ============================================================
    -// dvxGetColor
    -// ============================================================
    -
    -void dvxGetColor(const AppContextT *ctx, ColorIdE id, uint8_t *r, uint8_t *g, uint8_t *b) {
    -    if (id < 0 || id >= ColorCountE) {
    -        *r = *g = *b = 0;
    -        return;
    -    }
    -
    -    *r = ctx->colorRgb[id][0];
    -    *g = ctx->colorRgb[id][1];
    -    *b = ctx->colorRgb[id][2];
    -}
    -
    -
    -// ============================================================
    -// dvxGetColors
    -// ============================================================
    -
    -const ColorSchemeT *dvxGetColors(const AppContextT *ctx) {
    -    return &ctx->colors;
    -}
    -
    -
    -// ============================================================
    -// dvxGetDisplay
    -// ============================================================
    -
    -DisplayT *dvxGetDisplay(AppContextT *ctx) {
    -    return &ctx->display;
    -}
    -
    -
    -// ============================================================
    -// dvxGetFont
    -// ============================================================
    -
    -const BitmapFontT *dvxGetFont(const AppContextT *ctx) {
    -    return &ctx->font;
    -}
    -
    -
    -// ============================================================
    -// dvxGetVideoModes
    -// ============================================================
    -
    -const VideoModeInfoT *dvxGetVideoModes(const AppContextT *ctx, int32_t *count) {
    -    *count = (int32_t)arrlen(ctx->videoModes);
    -    return ctx->videoModes;
    -}
    -
    -
    -// ============================================================
    -// dvxInit
    -// ============================================================
    -//
    -// One-shot initialization of all GUI subsystems. The layered init order
    -// matters: video must be up before draw ops can be selected (since draw
    -// ops depend on pixel format), and colors must be packed after the
    -// display format is known.
    -
    -int32_t dvxInit(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
    -    memset(ctx, 0, sizeof(*ctx));
    -
    -    platformInit();
    -    platformKeyUpInit();
    -
    -    // Enumerate available video modes BEFORE setting one.  Some VBE
    -    // BIOSes return a stale or truncated mode list once a graphics
    -    // mode is active, so we must query while still in text mode.
    -    // (ctx was memset above, so videoModes is already NULL.)
    -    platformVideoEnumModes(enumModeCb, ctx);
    -
    -    if (videoInit(&ctx->display, requestedW, requestedH, preferredBpp) != 0) {
    -        dvxLog("App: video init failed for %dx%d@%dbpp", requestedW, requestedH, preferredBpp);
    -        return -1;
    -    }
    -
    -    // Draw ops are pixel-format-dependent function pointers (e.g., 16bpp
    -    // vs 32bpp span fill). Selected once here, then used everywhere.
    -    drawInit(&ctx->blitOps, &ctx->display);
    -
    -    wmInit(&ctx->stack);
    -    dirtyListInit(&ctx->dirty);
    -
    -    // 8x16 is the only font size currently supported. Fixed-width bitmap
    -    // fonts are used throughout because variable-width text measurement
    -    // would add complexity and cost on every text draw without much
    -    // benefit at 640x480 resolution.
    -    ctx->font = dvxFont8x16;
    -
    -    memcpy(ctx->cursors, dvxCursors, sizeof(dvxCursors));
    -    ctx->cursorId = CURSOR_ARROW;
    -
    -    initColorScheme(ctx);
    -
    -    platformMouseInit(ctx->display.width, ctx->display.height);
    -    ctx->hasMouseWheel = platformMouseWheelInit();
    -    ctx->mouseX        = ctx->display.width / 2;
    -    ctx->mouseY        = ctx->display.height / 2;
    -    ctx->prevMouseX    = ctx->mouseX;
    -    ctx->prevMouseY    = ctx->mouseY;
    -
    -    ctx->running            = true;
    -    ctx->lastIconClickId    = -1;
    -    ctx->lastIconClickTime  = 0;
    -    ctx->lastCloseClickId   = -1;
    -    ctx->lastCloseClickTime = 0;
    -    ctx->lastTitleClickId   = -1;
    -    ctx->lastTitleClickTime = 0;
    -    ctx->wheelDirection     = 1;
    -    ctx->wheelStep          = MOUSE_WHEEL_STEP_DEFAULT;
    -    ctx->dblClickTicks      = DBLCLICK_THRESHOLD;
    -    sDblClickTicks          = DBLCLICK_THRESHOLD;
    -
    -    // Pre-compute fixed-point 16.16 reciprocal of character height so
    -    // popup menu item index calculation can use multiply+shift instead
    -    // of division. On a 486, integer divide is 40+ cycles; this
    -    // reciprocal trick reduces it to ~10 cycles (imul + shr).
    -    ctx->charHeightRecip    = ((uint32_t)FP_ONE + (uint32_t)ctx->font.charHeight - 1) / (uint32_t)ctx->font.charHeight;
    -
    -    // Dirty the entire screen so the first compositeAndFlush paints everything
    -    dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    -
    -    return 0;
    -}
    -
    -
    -void dvxHideWindow(AppContextT *ctx, WindowT *win) {
    -    if (!win) {
    -        return;
    -    }
    -
    -    // A hidden window must not stay registered as the modal window, or
    -    // the modal input gate would drop all clicks and deadlock input.
    -    // The callee owns this invariant (mirroring dvxDestroyWindow), and
    -    // it runs before the visibility early-return so the clear is
    -    // idempotent even for already-hidden windows.
    -    if (ctx->modalWindow == win) {
    -        ctx->modalWindow = NULL;
    -    }
    -
    -    if (!win->visible) {
    -        return;
    -    }
    -
    -    dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    -    win->visible = false;
    -
    -    // Keyboard input follows focusedIdx unconditionally, so a hidden
    -    // window must not keep it. Move focus to the topmost visible window;
    -    // if there is none, drop focus entirely (blurring this window).
    -    if (ctx->stack.focusedIdx >= 0 && ctx->stack.windows[ctx->stack.focusedIdx] == win) {
    -        focusTopmostAlive(ctx);
    -
    -        if (ctx->stack.windows[ctx->stack.focusedIdx] == win) {
    -            ctx->stack.focusedIdx = -1;
    -            win->focused          = false;
    -
    -            if (win->onBlur) {
    -                WIN_CALLBACK(ctx, win, win->onBlur(win));
    -            }
    -        }
    -    }
    -}
    -
    -
    -bool dvxImageInfo(const char *path, int32_t *outW, int32_t *outH) {
    -    if (!path) {
    -        return false;
    -    }
    -
    -    int w = 0;
    -    int h = 0;
    -    int comp = 0;
    -
    -    if (stbi_info(path, &w, &h, &comp)) {
    -        if (outW) { *outW = w; }
    -        if (outH) { *outH = h; }
    -        return true;
    -    }
    -
    -    return false;
    -}
    -
    -
    -void dvxInvalidateRect(AppContextT *ctx, WindowT *win, int32_t x, int32_t y, int32_t w, int32_t h) {
    -    // Convert from content-relative to screen coordinates
    -    int32_t screenX = win->x + win->contentX + x;
    -    int32_t screenY = win->y + win->contentY + y;
    -
    -    dirtyListAdd(&ctx->dirty, screenX, screenY, w, h);
    -}
    -
    -
    -// ============================================================
    -// dvxInvalidateTooltip
    -// ============================================================
    -//
    -// Hides the on-screen tooltip when it borrows `text` (pass NULL to hide
    -// unconditionally).  ctx->tooltipText is a borrowed pointer: widget
    -// destruction and wgtSetTooltip reassignment can free the string it
    -// points at, so every such site must invalidate it here or the
    -// compositor would keep drawing freed memory until the mouse moves.
    -
    -void dvxInvalidateTooltip(AppContextT *ctx, const char *text) {
    -    if (!ctx || !ctx->tooltipText) {
    -        return;
    -    }
    -
    -    if (text && ctx->tooltipText != text) {
    -        return;
    -    }
    -
    -    dirtyListAdd(&ctx->dirty, ctx->tooltipX, ctx->tooltipY, ctx->tooltipW, ctx->tooltipH);
    -    ctx->tooltipText = NULL;
    -}
    -
    -
    -// ============================================================
    -// dvxInvalidateWindow
    -// ============================================================
    -
    -void dvxInvalidateWindow(AppContextT *ctx, WindowT *win) {
    -    // A window queued for deferred destruction is inert: its app state
    -    // (widget userData etc.) may already be torn down, so never paint it.
    -    if (win->destroyPending) {
    -        return;
    -    }
    -
    -    // Call the window's paint callback to update the content buffer
    -    // before marking the screen dirty. This means raw-paint apps only
    -    // need to call dvxInvalidateWindow  -- onPaint fires automatically.
    -    // Set PAINT_FULL for widget-managed windows: widgetOnPaint only
    -    // relays out + background-clears when paintNeeded >= PAINT_FULL,
    -    // and callers invalidating "the whole window" (theme changes,
    -    // scroll, color-scheme swaps) need that full repaint rather than
    -    // the dirty-widget-only partial repaint the default would give.
    -    win->paintNeeded = PAINT_FULL;
    -
    -    // Depth-bracket the callback (and the trailing writes) so an onPaint
    -    // that destroys its own window defers -- the writes below then land
    -    // on still-live memory and the flush reclaims the window afterwards.
    -    ctx->dispatchDepth++;
    -
    -    if (win->onPaint) {
    -        RectT fullRect = {0, 0, win->contentW, win->contentH};
    -        WIN_CALLBACK(ctx, win, win->onPaint(win, &fullRect));
    -    }
    -
    -    win->iconNeedsRefresh = true;
    -    dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    -
    -    ctx->dispatchDepth--;
    -
    -    if (ctx->dispatchDepth == 0 && ctx->pendingDestroyCount > 0) {
    -        flushPendingDestroys(ctx);
    -    }
    -}
    -
    -
    -// ============================================================
    -// dvxLoadImage
    -// ============================================================
    -//
    -// Public image loading API. Loads any image file supported by stb_image
    -// (BMP, PNG, JPEG, GIF, etc.) and converts the RGB pixels to the
    -// display's native pixel format for direct use with rectCopy, wgtImage,
    -// wgtImageButton, or any other pixel-data consumer. The caller owns the
    -// returned buffer and must free it with dvxFreeImage().
    -
    -uint8_t *dvxLoadImage(const AppContextT *ctx, const char *path, int32_t *outW, int32_t *outH, int32_t *outPitch) {
    -    if (!ctx || !path) {
    -        return NULL;
    -    }
    -
    -    int imgW;
    -    int imgH;
    -    int channels;
    -    uint8_t *rgb = stbi_load(path, &imgW, &imgH, &channels, RGB_CHANNELS);
    -
    -    return finishLoadImage(&ctx->display, rgb, imgW, imgH, outW, outH, outPitch);
    -}
    -
    -
    -uint8_t *dvxLoadImageAlpha(const AppContextT *ctx, const uint8_t *data, int32_t dataLen, int32_t *outW, int32_t *outH, int32_t *outPitch, bool *outHasAlpha, uint32_t *outKeyColor) {
    -    if (!ctx || !data || dataLen <= 0) {
    -        return NULL;
    -    }
    -
    -    const DisplayT *d = &ctx->display;
    -
    -    int imgW;
    -    int imgH;
    -    int channels;
    -    uint8_t *rgba = stbi_load_from_memory(data, dataLen, &imgW, &imgH, &channels, RGBA_CHANNELS);
    -
    -    if (!rgba) {
    -        return NULL;
    -    }
    -
    -    uint32_t keyColor = packColor(d, KEY_COLOR_R, KEY_COLOR_G, KEY_COLOR_B);
    -    int32_t  pitch;
    -    uint8_t *buf      = convertRgbToNative(d, rgba, RGBA_CHANNELS, imgW, imgH, keyColor, outHasAlpha, &pitch);
    -
    -    stbi_image_free(rgba);
    -
    -    if (!buf) {
    -        return NULL;
    -    }
    -
    -    if (outW) {
    -        *outW = imgW;
    -    }
    -
    -    if (outH) {
    -        *outH = imgH;
    -    }
    -
    -    if (outPitch) {
    -        *outPitch = pitch;
    -    }
    -
    -    if (outKeyColor) {
    -        *outKeyColor = keyColor;
    -    }
    -
    -    return buf;
    -}
    -
    -
    -uint8_t *dvxLoadImageFromMemory(const AppContextT *ctx, const uint8_t *data, int32_t dataLen, int32_t *outW, int32_t *outH, int32_t *outPitch) {
    -    if (!ctx || !data || dataLen <= 0) {
    -        return NULL;
    -    }
    -
    -    int imgW;
    -    int imgH;
    -    int channels;
    -    uint8_t *rgb = stbi_load_from_memory(data, dataLen, &imgW, &imgH, &channels, RGB_CHANNELS);
    -
    -    return finishLoadImage(&ctx->display, rgb, imgW, imgH, outW, outH, outPitch);
    -}
    -
    -
    -bool dvxLoadTheme(AppContextT *ctx, const char *filename) {
    -    FILE *fp = fopen(filename, "rb");
    -
    -    if (!fp) {
    -        return false;
    -    }
    -
    -    char line[256];
    -
    -    while (fgets(line, sizeof(line), fp)) {
    -        // Strip trailing whitespace
    -        char *end = line + strlen(line) - 1;
    -
    -        while (end >= line && (*end == '\r' || *end == '\n' || *end == ' ')) {
    -            *end-- = '\0';
    -        }
    -
    -        char *p = (char *)dvxSkipWs(line);
    -
    -        // Skip comments, blank lines, section headers
    -        if (*p == '\0' || *p == ';' || *p == '#' || *p == '[') {
    -            continue;
    -        }
    -
    -        // Parse key = r,g,b
    -        char *eq = strchr(p, '=');
    -
    -        if (!eq) {
    -            continue;
    -        }
    -
    -        *eq = '\0';
    -
    -        // Trim key
    -        char *key = p;
    -        char *keyEnd = eq - 1;
    -
    -        while (keyEnd >= key && (*keyEnd == ' ' || *keyEnd == '\t')) {
    -            *keyEnd-- = '\0';
    -        }
    -
    -        // Parse r,g,b
    -        const char *val = dvxSkipWs(eq + 1);
    -
    -        int r;
    -        int g;
    -        int b;
    -
    -        if (sscanf(val, "%d,%d,%d", &r, &g, &b) != 3) {
    -            continue;
    -        }
    -
    -        // Find matching color name
    -        for (int32_t i = 0; i < ColorCountE; i++) {
    -            if (strcmp(key, sColorNames[i]) == 0) {
    -                ctx->colorRgb[i][0] = (uint8_t)clampInt(r, 0, UINT8_MAX);
    -                ctx->colorRgb[i][1] = (uint8_t)clampInt(g, 0, UINT8_MAX);
    -                ctx->colorRgb[i][2] = (uint8_t)clampInt(b, 0, UINT8_MAX);
    -                break;
    -            }
    -        }
    -    }
    -
    -    fclose(fp);
    -    dvxApplyColorScheme(ctx);
    -    return true;
    -}
    -
    -
    -// ============================================================
    -// dvxMaximizeWindow
    -// ============================================================
    -
    -void dvxMaximizeWindow(AppContextT *ctx, WindowT *win) {
    -    wmMaximize(&ctx->stack, &ctx->dirty, &ctx->display, win);
    -}
    -
    -
    -// ============================================================
    -// dvxMinimizeWindow
    -// ============================================================
    -
    -void dvxMinimizeWindow(AppContextT *ctx, WindowT *win) {
    -    wmMinimize(&ctx->stack, &ctx->dirty, win);
    -
    -    // wmMinimize only dirties the window's former rect; the icon strip
    -    // must be dirtied too or the new minimized icon never composites
    -    // (matches the sys-menu and minimize-gadget interactive paths).
    -    int32_t iconY;
    -    int32_t iconH;
    -    wmMinimizedIconRect(&ctx->stack, &ctx->display, &iconY, &iconH);
    -    dirtyListAdd(&ctx->dirty, 0, iconY, ctx->display.width, iconH);
    -}
    -
    -
    -// ============================================================
    -// dvxQuit
    -// ============================================================
    -
    -void dvxQuit(AppContextT *ctx) {
    -    ctx->running = false;
    -}
    -
    -
    -void dvxRaiseWindow(AppContextT *ctx, WindowT *win) {
    -    // A window queued for deferred destruction is INERT: raising it would
    -    // refocus it and fire callbacks into torn-down app state.
    -    if (!win || win->destroyPending) {
    -        return;
    -    }
    -
    -    for (int32_t i = 0; i < ctx->stack.count; i++) {
    -        if (ctx->stack.windows[i] == win) {
    -            wmRaiseWindow(&ctx->stack, &ctx->dirty, i);
    -            wmSetFocus(&ctx->stack, &ctx->dirty, ctx->stack.count - 1);
    -            return;
    -        }
    -    }
    -}
    -
    -
    -void dvxResetColorScheme(AppContextT *ctx) {
    -    memcpy(ctx->colorRgb, sDefaultColors, sizeof(sDefaultColors));
    -    dvxApplyColorScheme(ctx);
    -}
    -
    -
    -void dvxResizeWindow(AppContextT *ctx, WindowT *win, int32_t newW, int32_t newH) {
    -    // Dirty old position
    -    dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    -
    -    // Resize
    -    win->w = newW;
    -    win->h = newH;
    -
    -    // Shift position so the window stays fully on screen
    -    if (win->x + newW > ctx->display.width) {
    -        win->x = ctx->display.width - newW;
    -    }
    -
    -    if (win->y + newH > ctx->display.height) {
    -        win->y = ctx->display.height - newH;
    -    }
    -
    -    if (win->x < 0) {
    -        win->x = 0;
    -    }
    -
    -    if (win->y < 0) {
    -        win->y = 0;
    -    }
    -
    -    // Commit the new geometry eagerly (content rect, buffer, onResize) but
    -    // DEFER the repaint: paintNeeded = PAINT_FULL lets the deferred-paint
    -    // flush in dvxUpdate coalesce repeated programmatic resizes (e.g. a
    -    // BASIC form setting Width then Height in one tick) into a single
    -    // paint per frame.  The flush fires onPaint for raw-paint windows too,
    -    // so they are not left blank like the old wgtInvalidate-only path.
    -    // Depth-bracketed: onResize may destroy this window; the bracket
    -    // defers the destroy past the trailing writes so they never touch
    -    // freed memory.
    -    ctx->dispatchDepth++;
    -
    -    wmUpdateContentRect(win);
    -
    -    if (wmReallocContentBuf(win, &ctx->display) == 0) {
    -        if (win->onResize) {
    -            WIN_CALLBACK(ctx, win, win->onResize(win, win->contentW, win->contentH));
    -        }
    -
    -        win->paintNeeded = PAINT_FULL;
    -    }
    -
    -    // Dirty new position
    -    dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    -
    -    ctx->dispatchDepth--;
    -
    -    if (ctx->dispatchDepth == 0 && ctx->pendingDestroyCount > 0) {
    -        flushPendingDestroys(ctx);
    -    }
    -}
    -
    -
    -void *dvxResLoadData(const char *dxePath, const char *resName, uint32_t *outSize) {
    -    if (!dxePath || !resName) {
    -        return NULL;
    -    }
    -
    -    DvxResHandleT *res = dvxResOpen(dxePath);
    -
    -    if (!res) {
    -        return NULL;
    -    }
    -
    -    void *data = dvxResRead(res, resName, outSize);
    -    dvxResClose(res);
    -    return data;
    -}
    -
    -
    -uint8_t *dvxResLoadIcon(AppContextT *ctx, const char *dxePath, const char *resName, int32_t *outW, int32_t *outH, int32_t *outPitch) {
    -    if (!ctx || !dxePath || !resName) {
    -        return NULL;
    -    }
    -
    -    DvxResHandleT *res = dvxResOpen(dxePath);
    -
    -    if (!res) {
    -        return NULL;
    -    }
    -
    -    uint32_t size = 0;
    -    void *buf = dvxResRead(res, resName, &size);
    -    dvxResClose(res);
    -
    -    if (!buf) {
    -        return NULL;
    -    }
    -
    -    uint8_t *pixels = dvxLoadImageFromMemory(ctx, (const uint8_t *)buf, (int32_t)size, outW, outH, outPitch);
    -    free(buf);
    -    return pixels;
    -}
    -
    -
    -bool dvxResLoadText(const char *dxePath, const char *resName, char *buf, int32_t bufSize) {
    -    if (!dxePath || !resName || !buf || bufSize <= 0) {
    -        return false;
    -    }
    -
    -    DvxResHandleT *res = dvxResOpen(dxePath);
    -
    -    if (!res) {
    -        return false;
    -    }
    -
    -    uint32_t size = 0;
    -    void *data = dvxResRead(res, resName, &size);
    -    dvxResClose(res);
    -
    -    if (!data) {
    -        return false;
    -    }
    -
    -    int32_t copyLen = (int32_t)size < bufSize - 1 ? (int32_t)size : bufSize - 1;
    -    memcpy(buf, data, copyLen);
    -    buf[copyLen] = '\0';
    -    free(data);
    -    return true;
    -}
    -
    -
    -void dvxRun(AppContextT *ctx) {
    -    while (dvxUpdate(ctx)) {
    -        // dvxUpdate returns false when the GUI wants to exit
    -    }
    -}
    -
    -
    -// ============================================================
    -// dvxSaveImage
    -// ============================================================
    -//
    -// Save native-format pixel data to a PNG file. Converts from the
    -// display's native pixel format to RGB, then encodes as PNG via
    -// stb_image_write. This is the general-purpose image save function;
    -// dvxScreenshot and dvxWindowScreenshot are convenience wrappers
    -// around it for common use cases.
    -
    -int32_t dvxSaveImage(const AppContextT *ctx, const uint8_t *data, int32_t w, int32_t h, int32_t pitch, const char *path) {
    -    if (!ctx || !data || !path || w <= 0 || h <= 0) {
    -        return -1;
    -    }
    -
    -    uint8_t *rgb = bufferToRgb(&ctx->display, data, w, h, pitch);
    -
    -    if (!rgb) {
    -        return -1;
    -    }
    -
    -    int32_t result = stbi_write_png(path, w, h, RGB_CHANNELS, rgb, w * RGB_CHANNELS) ? 0 : -1;
    -
    -    free(rgb);
    -
    -    return result;
    -}
    -
    -
    -// ============================================================
    -// dvxSaveTheme
    -// ============================================================
    -
    -bool dvxSaveTheme(const AppContextT *ctx, const char *filename) {
    -    FILE *fp = fopen(filename, "wb");
    -
    -    if (!fp) {
    -        return false;
    -    }
    -
    -    fprintf(fp, "; DVX Color Theme\r\n\r\n[colors]\r\n");
    -
    -    for (int32_t i = 0; i < ColorCountE; i++) {
    -        fprintf(fp, "%-20s = %d,%d,%d\r\n", sColorNames[i], ctx->colorRgb[i][0], ctx->colorRgb[i][1], ctx->colorRgb[i][2]);
    -    }
    -
    -    fclose(fp);
    -    return true;
    -}
    -
    -
    -// ============================================================
    -// dvxScreenshot
    -// ============================================================
    -//
    -// Save the entire screen (backbuffer) to a PNG file. Uses the backbuffer
    -// rather than the LFB because reading from video memory through PCI/ISA
    -// is extremely slow on period hardware (uncacheable MMIO reads). The
    -// backbuffer is in system RAM and is always coherent with the LFB since
    -// we only write to the LFB, never read.
    -
    -int32_t dvxScreenshot(AppContextT *ctx, const char *path) {
    -    DisplayT *d = &ctx->display;
    -    int32_t   result;
    -
    -    dvxSetBusy(ctx, true);
    -    result = dvxSaveImage(ctx, d->backBuf, d->width, d->height, d->pitch, path);
    -    dvxSetBusy(ctx, false);
    -
    -    return result;
    -}
    -
    -
    -// ============================================================
    -// dvxSetBusy
    -// ============================================================
    -
    -void dvxSetBusy(AppContextT *ctx, bool busy) {
    -    ctx->busy = busy;
    -
    -    if (busy) {
    -        ctx->cursorId = CURSOR_BUSY;
    -    } else {
    -        ctx->cursorId = CURSOR_ARROW;
    -    }
    -
    -    // dirtyCursorArea covers the worst-case union of all cursor shapes at the
    -    // pointer position, so a single call erases the old shape and exposes the
    -    // new one (both are drawn at the same mouseX/mouseY).
    -    dirtyCursorArea(ctx, ctx->mouseX, ctx->mouseY);
    -
    -    // Flush immediately so the cursor change is visible before
    -    // the blocking operation starts (or after it ends).
    -    // Skip if display isn't initialized yet (startup wallpaper load).
    -    if (ctx->display.backBuf) {
    -        compositeAndFlush(ctx);
    -    }
    -}
    -
    -
    -// ============================================================
    -// dvxSetColor
    -// ============================================================
    -
    -void dvxSetColor(AppContextT *ctx, ColorIdE id, uint8_t r, uint8_t g, uint8_t b) {
    -    if (id < 0 || id >= ColorCountE) {
    -        return;
    -    }
    -
    -    ctx->colorRgb[id][0] = r;
    -    ctx->colorRgb[id][1] = g;
    -    ctx->colorRgb[id][2] = b;
    -
    -    uint32_t packed = packColor(&ctx->display, r, g, b);
    -    *colorSlot(&ctx->colors, id) = packed;
    -
    -    invalidateAllWindows(ctx);
    -}
    -
    -
    -// ============================================================
    -// dvxSetMouseConfig
    -// ============================================================
    -
    -void dvxSetMouseConfig(AppContextT *ctx, int32_t wheelDir, int32_t dblClickMs, int32_t accelThreshold, int32_t mickeyRatio, int32_t wheelStep) {
    -    ctx->wheelDirection = (wheelDir < 0) ? -1 : 1;
    -    ctx->dblClickTicks  = (clock_t)dblClickMs * CLOCKS_PER_SEC / 1000;
    -    sDblClickTicks      = ctx->dblClickTicks;
    -
    -    if (wheelStep < MOUSE_WHEEL_STEP_MIN) {
    -        wheelStep = MOUSE_WHEEL_STEP_MIN;
    -    }
    -
    -    if (wheelStep > MOUSE_WHEEL_STEP_MAX) {
    -        wheelStep = MOUSE_WHEEL_STEP_MAX;
    -    }
    -
    -    ctx->wheelStep = wheelStep;
    -
    -    // Cache the applied values on the context so callers (e.g. the control
    -    // panel snapshot) read live state instead of re-parsing the INI, keeping
    -    // a single source of truth. A non-positive value means "leave unchanged",
    -    // so the previously applied value is preserved.
    -    if (accelThreshold > 0) {
    -        ctx->accelThreshold = accelThreshold;
    -        platformMouseSetAccel(accelThreshold);
    -    }
    -
    -    if (mickeyRatio > 0) {
    -        ctx->mickeyRatio = mickeyRatio;
    -        platformMouseSetMickeys(mickeyRatio, mickeyRatio);
    -    }
    -}
    -
    -
    -// ============================================================
    -// dvxSetTitle
    -// ============================================================
    -
    -void dvxSetTitle(AppContextT *ctx, WindowT *win, const char *title) {
    -    wmSetTitle(win, &ctx->dirty, title);
    -
    -    if (ctx->onTitleChange) {
    -        ctx->onTitleChange(ctx->titleChangeCtx);
    -    }
    -}
    -
    -
    -// ============================================================
    -// dvxSetWallpaper
    -// ============================================================
    -
    -bool dvxSetWallpaper(AppContextT *ctx, const char *path) {
    -    // buildWallpaperBuf pumps dvxUpdate to keep the UI alive, and a timer
    -    // or idle callback fired from there may call back in here. A nested
    -    // call would free the buffer being built and clear busy early, so
    -    // reject it outright.
    -    static bool sBuilding = false;
    -
    -    if (sBuilding) {
    -        return false;
    -    }
    -
    -    free(ctx->wallpaperBuf);
    -    ctx->wallpaperBuf   = NULL;
    -    ctx->wallpaperPitch = 0;
    -
    -    if (!path) {
    -        ctx->wallpaperPath[0] = '\0';
    -        dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    -        return true;
    -    }
    -
    -    strncpy(ctx->wallpaperPath, path, sizeof(ctx->wallpaperPath) - 1);
    -    ctx->wallpaperPath[sizeof(ctx->wallpaperPath) - 1] = '\0';
    -
    -    dvxSetBusy(ctx, true);
    -
    -    int imgW;
    -    int imgH;
    -    int channels;
    -    uint8_t *rgb = stbi_load(path, &imgW, &imgH, &channels, 3);
    -
    -    if (!rgb) {
    -        dvxSetBusy(ctx, false);
    -        return false;
    -    }
    -
    -    int32_t pitch = ctx->display.width * ctx->display.format.bytesPerPixel;
    -
    -    sBuilding           = true;
    -    ctx->wallpaperBuf   = buildWallpaperBuf(ctx, rgb, imgW, imgH, ctx->wallpaperMode);
    -    ctx->wallpaperPitch = pitch;
    -    sBuilding           = false;
    -    dvxSetBusy(ctx, false);
    -
    -    stbi_image_free(rgb);
    -
    -    if (!ctx->wallpaperBuf) {
    -        return false;
    -    }
    -
    -    dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    -    return true;
    -}
    -
    -
    -// ============================================================
    -// dvxSetWallpaperMode
    -// ============================================================
    -
    -void dvxSetWallpaperMode(AppContextT *ctx, WallpaperModeE mode) {
    -    ctx->wallpaperMode = mode;
    -
    -    if (ctx->wallpaperPath[0]) {
    -        dvxSetWallpaper(ctx, ctx->wallpaperPath);
    -    }
    -}
    -
    -
    -// ============================================================
    -// dvxSetWindowIcon
    -// ============================================================
    -
    -int32_t dvxSetWindowIcon(AppContextT *ctx, WindowT *win, const char *path) {
    -    return wmSetIcon(win, path, &ctx->display);
    -}
    -
    -
    -void dvxShowWindow(AppContextT *ctx, WindowT *win) {
    -    // A window queued for deferred destruction is INERT: re-showing it
    -    // would let hit-testing and focus fire callbacks into app state that
    -    // may already be torn down.
    -    if (!win || win->visible || win->destroyPending) {
    -        return;
    -    }
    -
    -    win->visible = true;
    -    dvxInvalidateWindow(ctx, win);
    -}
    -
    -
    -void dvxShutdown(AppContextT *ctx) {
    -    platformKeyUpShutdown();
    -
    -    // Destroy all remaining windows
    -    while (ctx->stack.count > 0) {
    -        wmDestroyWindow(&ctx->stack, ctx->stack.windows[ctx->stack.count - 1]);
    -    }
    -
    -    free(ctx->stack.windows);
    -    ctx->stack.windows = NULL;
    -    ctx->stack.count   = 0;
    -    ctx->stack.cap     = 0;
    -
    -    free(ctx->popup.parentStack);
    -    ctx->popup.parentStack = NULL;
    -    ctx->popup.parentCap   = 0;
    -
    -    free(ctx->dirty.rects);
    -    ctx->dirty.rects = NULL;
    -    ctx->dirty.count = 0;
    -    ctx->dirty.cap   = 0;
    -
    -    free(ctx->wallpaperBuf);
    -    ctx->wallpaperBuf = NULL;
    -    arrfree(ctx->videoModes);
    -    ctx->videoModes = NULL;
    -    videoShutdown(&ctx->display);
    -}
    -
    -
    -uint32_t dvxTextHash(const char *text) {
    -    if (!text) {
    -        return 0;
    -    }
    -
    -    uint32_t h = 5381;
    -
    -    while (*text) {
    -        h = ((h << 5) + h) ^ (uint8_t)*text;
    -        text++;
    -    }
    -
    -    return h;
    -}
    -
    -
    -// Tile windows in a grid. The grid dimensions are chosen so columns =
    -// ceil(sqrt(n)), which produces a roughly square grid. This is better than
    -// always using rows or columns because it maximizes the minimum dimension
    -// of each tile (a 1xN or Nx1 layout makes windows very narrow or short).
    -// The last row may have fewer windows; those get wider tiles to fill the
    -// remaining screen width, avoiding dead space.
    -//
    -// The integer sqrt is computed by a simple loop rather than calling sqrt()
    -// to avoid pulling in floating-point math on DJGPP targets.
    -
    -void dvxTileWindows(AppContextT *ctx) {
    -    int32_t screenW = ctx->display.width;
    -    int32_t screenH = ctx->display.height;
    -    int32_t count   = countVisibleWindows(ctx);
    -
    -    if (count == 0) {
    -        return;
    -    }
    -
    -    // Integer ceil(sqrt(count)) for column count
    -    int32_t cols = 1;
    -
    -    while (cols * cols < count) {
    -        cols++;
    -    }
    -
    -    int32_t rows = (count + cols - 1) / cols;
    -    int32_t tileW = screenW / cols;
    -    int32_t tileH = screenH / rows;
    -
    -    if (tileW < MIN_WINDOW_W) {
    -        tileW = MIN_WINDOW_W;
    -    }
    -
    -    if (tileH < MIN_WINDOW_H) {
    -        tileH = MIN_WINDOW_H;
    -    }
    -
    -    int32_t slot = 0;
    -
    -    for (int32_t i = 0; i < ctx->stack.count; i++) {
    -        WindowT *win = ctx->stack.windows[i];
    -
    -        if (win->minimized || !win->visible) {
    -            continue;
    -        }
    -
    -        int32_t row = slot / cols;
    -        int32_t col = slot % cols;
    -
    -        // Last row: fewer windows get wider tiles
    -        int32_t remaining = count - row * cols;
    -        int32_t rowCols   = (remaining < cols) ? remaining : cols;
    -        int32_t cellW     = screenW / rowCols;
    -
    -        repositionWindow(ctx, win, col * cellW, row * tileH, cellW, tileH);
    -
    -        slot++;
    -    }
    -}
    -
    -
    -// ============================================================
    -// dvxTileWindowsH
    -// ============================================================
    -//
    -// Horizontal tiling: windows side by side left to right, each the full
    -// screen height. Good for comparing two documents or viewing output
    -// alongside source. With many windows the tiles become very narrow, but
    -// MIN_WINDOW_W prevents them from becoming unusably small.
    -
    -void dvxTileWindowsH(AppContextT *ctx) {
    -    int32_t screenW = ctx->display.width;
    -    int32_t screenH = ctx->display.height;
    -    int32_t count   = countVisibleWindows(ctx);
    -
    -    if (count == 0) {
    -        return;
    -    }
    -
    -    int32_t tileW = screenW / count;
    -
    -    if (tileW < MIN_WINDOW_W) {
    -        tileW = MIN_WINDOW_W;
    -    }
    -
    -    int32_t slot = 0;
    -
    -    for (int32_t i = 0; i < ctx->stack.count; i++) {
    -        WindowT *win = ctx->stack.windows[i];
    -
    -        if (win->minimized || !win->visible) {
    -            continue;
    -        }
    -
    -        repositionWindow(ctx, win, slot * tileW, 0, tileW, screenH);
    -
    -        slot++;
    -    }
    -}
    -
    -
    -// ============================================================
    -// dvxTileWindowsV
    -// ============================================================
    -//
    -// Vertical tiling: windows stacked top to bottom, each the full screen
    -// width. The complement of dvxTileWindowsH.
    -
    -void dvxTileWindowsV(AppContextT *ctx) {
    -    int32_t screenW = ctx->display.width;
    -    int32_t screenH = ctx->display.height;
    -    int32_t count   = countVisibleWindows(ctx);
    -
    -    if (count == 0) {
    -        return;
    -    }
    -
    -    int32_t tileH = screenH / count;
    -
    -    if (tileH < MIN_WINDOW_H) {
    -        tileH = MIN_WINDOW_H;
    -    }
    -
    -    int32_t slot = 0;
    -
    -    for (int32_t i = 0; i < ctx->stack.count; i++) {
    -        WindowT *win = ctx->stack.windows[i];
    -
    -        if (win->minimized || !win->visible) {
    -            continue;
    -        }
    -
    -        repositionWindow(ctx, win, 0, slot * tileH, screenW, tileH);
    -
    -        slot++;
    -    }
    -}
    -
    -
    -// ============================================================
    -// dvxUpdate
    -// ============================================================
    -//
    -// Single iteration of the main event loop. This is the fundamental
    -// heartbeat of the GUI. The sequence is:
    -//   1. Poll hardware (mouse position/buttons, keyboard buffer)
    -//   2. Dispatch events (route input to windows, menus, widgets)
    -//   3. Update tooltip visibility
    -//   4. Poll ANSI terminal widgets (check for new data from PTYs)
    -//   5. Periodic tasks (minimized icon thumbnail refresh)
    -//   6. Composite dirty regions and flush to LFB
    -//   7. If nothing was dirty: run idle callback or yield CPU
    -//
    -// The idle callback mechanism exists so applications can do background
    -// work (e.g., polling serial ports, processing network data) when the
    -// GUI has nothing to paint. Without it, the loop would busy-wait or
    -// yield the CPU slice. With it, the application gets a callback to do
    -// useful work. platformYield is the fallback  -- it calls INT 28h (DOS
    -// idle) or SDL_Delay (Linux) to avoid burning CPU when truly idle.
    -
    -bool dvxUpdate(AppContextT *ctx) {
    -    if (!ctx->running) {
    -        return false;
    -    }
    -
    -    // Everything inside this depth-tracked section may run user
    -    // callbacks.  dvxDestroyWindow defers actual teardown while
    -    // dispatchDepth > 0 (see deferDestroyWindow); nested dvxUpdate
    -    // calls from modal dialog loops stack the depth, so the flush only
    -    // happens once every callback frame has unwound.
    -    ctx->dispatchDepth++;
    -
    -    pollMouse(ctx);
    -    pollKeyboard(ctx);
    -    dispatchEvents(ctx);
    -    updateTooltip(ctx);
    -    pollWidgets(ctx);
    -    if (sCursorBlinkFn) {
    -        sCursorBlinkFn();
    -    }
    -
    -    wgtUpdateTimers();
    -
    -    // Flush deferred paints. paintNeeded is set by wgtInvalidatePaint
    -    // (PARTIAL) or wgtInvalidate (FULL). Multiple calls per frame are
    -    // batched into one paint -- the highest level wins.  This loop runs
    -    // INSIDE the depth-tracked section so an onPaint that destroys its
    -    // own window defers: the paintNeeded/icon writes below then land on
    -    // live memory and the stack is never compacted mid-iteration.
    -    // Deferred-destroyed windows are skipped -- their app state may
    -    // already be torn down (this also covers nested dvxUpdate loops,
    -    // where the flush below is suppressed).
    -    for (int32_t i = 0; i < ctx->stack.count; i++) {
    -        WindowT *win = ctx->stack.windows[i];
    -
    -        if (win->destroyPending) {
    -            continue;
    -        }
    -
    -        if (win->paintNeeded && win->onPaint) {
    -            RectT fullRect = {0, 0, win->contentW, win->contentH};
    -            WIN_CALLBACK(ctx, win, win->onPaint(win, &fullRect));
    -            // widgetOnPaint clears paintNeeded for widget windows.
    -            // For raw-paint windows, clear it here.
    -            win->paintNeeded      = PAINT_NONE;
    -            win->iconNeedsRefresh = true;
    -            dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    -        }
    -    }
    -
    -    ctx->dispatchDepth--;
    -
    -    if (ctx->dispatchDepth == 0 && ctx->pendingDestroyCount > 0) {
    -        flushPendingDestroys(ctx);
    -    }
    -
    -    ctx->frameCount++;
    -
    -    if (ctx->frameCount % ICON_REFRESH_INTERVAL == 0) {
    -        refreshMinimizedIcons(ctx);
    -    }
    -
    -    if (ctx->dirty.count > 0) {
    -        compositeAndFlush(ctx);
    -    } else if (ctx->idleCallback) {
    -        ctx->idleCallback(ctx->idleCtx);
    -    } else {
    -        platformYield();
    -    }
    -
    -    // Release key-pressed button after one frame. The button was set to
    -    // "pressed" state in dispatchAccelKey; here we clear it and fire
    -    // onClick. The one-frame delay ensures the pressed visual state
    -    // renders before the callback runs (which may open a dialog, etc.).
    -    if (sKeyPressedBtn) {
    -        if (wclsHas(sKeyPressedBtn, WGT_METHOD_ON_DRAG_END)) {
    -            // Pass button center as coordinates so bounds check succeeds and onClick fires
    -            WidgetT *root = sKeyPressedBtn->window ? sKeyPressedBtn->window->widgetRoot : sKeyPressedBtn;
    -            wclsOnDragEnd(sKeyPressedBtn, root,
    -                          sKeyPressedBtn->x + sKeyPressedBtn->w / 2,
    -                          sKeyPressedBtn->y + sKeyPressedBtn->h / 2);
    -        }
    -
    -        wgtInvalidatePaint(sKeyPressedBtn);
    -        sKeyPressedBtn = NULL;
    -    }
    -
    -    ctx->prevMouseX       = ctx->mouseX;
    -    ctx->prevMouseY       = ctx->mouseY;
    -    ctx->prevMouseButtons = ctx->mouseButtons;
    -
    -    return ctx->running;
    -}
    -
    -
    -// ============================================================
    -// dvxWindowScreenshot
    -// ============================================================
    -//
    -// Save a window's content buffer to a PNG file. Because each window has
    -// its own persistent content buffer (not a shared backbuffer), this
    -// captures the full content even if the window is partially or fully
    -// occluded by other windows. This is a unique advantage of the per-window
    -// content buffer architecture.
    -
    -int32_t dvxWindowScreenshot(AppContextT *ctx, WindowT *win, const char *path) {
    -    int32_t result;
    -
    -    if (!win || !win->contentBuf) {
    -        return -1;
    -    }
    -
    -    dvxSetBusy(ctx, true);
    -    result = dvxSaveImage(ctx, win->contentBuf, win->contentW, win->contentH, win->contentPitch, path);
    -    dvxSetBusy(ctx, false);
    -
    -    return result;
    -}
    diff --git a/src/libs/kpunch/libdvx/dvxApp.h b/src/libs/kpunch/libdvx/dvxApp.h
    index 7978e65..4b840e0 100644
    --- a/src/libs/kpunch/libdvx/dvxApp.h
    +++ b/src/libs/kpunch/libdvx/dvxApp.h
    @@ -43,7 +43,6 @@
     #include "dvxComp.h"
     #include "dvxWm.h"
     
    -#include 
     
     // ============================================================
     // Application context
    @@ -87,17 +86,17 @@ typedef struct AppContextT {
         // Double-click detection for minimized window icons: timestamps and
         // window IDs track whether two clicks land on the same icon within
         // the system double-click interval.
    -    clock_t       lastIconClickTime;
    -    int32_t       lastIconClickId;    // window ID of last-clicked minimized icon (-1 = none)
    -    clock_t       lastCloseClickTime;
    -    int32_t       lastCloseClickId;   // window ID of last-clicked close gadget (-1 = none)
    -    clock_t       lastTitleClickTime;
    -    int32_t       lastTitleClickId;   // window ID of last-clicked title bar (-1 = none)
    +    PlatformTicksT lastIconClickTime;
    +    int32_t        lastIconClickId;    // window ID of last-clicked minimized icon (-1 = none)
    +    PlatformTicksT lastCloseClickTime;
    +    int32_t        lastCloseClickId;   // window ID of last-clicked close gadget (-1 = none)
    +    PlatformTicksT lastTitleClickTime;
    +    int32_t        lastTitleClickId;   // window ID of last-clicked title bar (-1 = none)
         // Minimized icon thumbnails are refreshed one per frame (round-robin)
         // rather than all at once, to amortize the cost of scaling the content
         // buffer down to icon size.
         int32_t       iconRefreshIdx;     // next minimized icon to refresh (staggered)
    -    int32_t       frameCount;         // frame counter for periodic tasks
    +    uint32_t      frameCount;         // frame counter for periodic tasks
         // The idle callback allows the host application (e.g. the DVX shell)
         // to do background work (poll serial ports, service DXE apps) during
         // frames where no GUI events occurred, instead of just yielding the CPU.
    @@ -126,7 +125,7 @@ typedef struct AppContextT {
         // Tooltip state  -- tooltip appears after the mouse hovers over a widget
         // with a tooltip string for a brief delay. Pre-computing W/H avoids
         // re-measuring on every paint frame.
    -    clock_t      tooltipHoverStart;    // when mouse stopped moving
    +    PlatformTicksT tooltipHoverStart;  // when mouse stopped moving
         const char  *tooltipText;          // text to show (NULL = hidden)
         int32_t      tooltipX;             // screen position
         int32_t      tooltipY;
    @@ -140,7 +139,7 @@ typedef struct AppContextT {
         // Mouse configuration (loaded from preferences)
         int32_t      wheelDirection;      // 1 = normal, -1 = reversed
         int32_t      wheelStep;           // lines per wheel notch (1-10, default 3)
    -    clock_t      dblClickTicks;       // double-click speed in clock() ticks
    +    PlatformTicksT dblClickTicks;     // double-click speed in platformClock() ticks
         int32_t      accelThreshold;      // last applied pointer-accel threshold
         int32_t      mickeyRatio;         // last applied mickeys-to-pixels ratio
         // Color scheme source RGB values (unpacked, for theme save/get)
    @@ -271,8 +270,8 @@ void dvxShowContextMenu(AppContextT *ctx, WindowT *win, MenuT *menu, int32_t scr
     // (plus chrome). Used for dialog boxes and other fixed-layout windows
     // where the window should shrink-wrap its content.
     void dvxFitWindow(AppContextT *ctx, WindowT *win);
    -void dvxFitWindowW(AppContextT *ctx, WindowT *win);
     void dvxFitWindowH(AppContextT *ctx, WindowT *win);
    +void dvxFitWindowW(AppContextT *ctx, WindowT *win);
     void dvxResizeWindow(AppContextT *ctx, WindowT *win, int32_t newW, int32_t newH);
     
     // Mark a sub-region of a window's content area as needing repaint. The
    diff --git a/src/libs/kpunch/libdvx/dvxDialog.c b/src/libs/kpunch/libdvx/dvxDialog.c
    index d3aafd7..2287312 100644
    --- a/src/libs/kpunch/libdvx/dvxDialog.c
    +++ b/src/libs/kpunch/libdvx/dvxDialog.c
    @@ -57,9 +57,11 @@
     
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     
    @@ -104,6 +106,25 @@
     #define CB_LIST_HEIGHT   120
     #define CB_PADDING       8
     
    +// Widgets per dialog that keep Enter for themselves (see dialogOnKey)
    +#define DLG_ENTER_OWNERS_MAX 3
    +
    +
    +// ============================================================
    +// Keyboard hook shared by every dialog
    +// ============================================================
    +//
    +// The widget layer only routes keys to the focused widget, so a dialog
    +// gets Escape/Enter semantics from a window-level onKey wrapper that
    +// runs before/after widgetOnKey.  Each dialog's state struct starts
    +// with this header and is installed as the window's userData.
    +
    +typedef struct {
    +    WidgetT *defaultBtn;                       // clicked on Enter when no button/owner has focus
    +    WidgetT *cancelBtn;                        // clicked on Escape
    +    WidgetT *enterOwners[DLG_ENTER_OWNERS_MAX]; // focused widgets that consume Enter themselves
    +} DialogKeysT;
    +
     
     // ============================================================
     // Word-wrap iterator types
    @@ -136,6 +157,7 @@ typedef struct {
     // breaks the nested dvxUpdate loop in each dvx*Dialog function.
     
     typedef struct {
    +    DialogKeysT  keys;        // must be first (window userData)
         AppContextT *ctx;
         int32_t      result;      // ID_OK, ID_CANCEL, ID_YES, ID_NO, etc.
         bool         done;        // set true to break the modal loop
    @@ -148,6 +170,7 @@ typedef struct {
     } MsgBoxStateT;
     
     typedef struct {
    +    DialogKeysT  keys;         // must be first (window userData)
         AppContextT *ctx;
         bool         done;
         bool         accepted;     // true = OK, false = Cancel
    @@ -157,20 +180,23 @@ typedef struct {
     } InputBoxStateT;
     
     typedef struct {
    -    bool     done;
    -    bool     accepted;
    -    int32_t *outVal;
    -    WidgetT *spinner;
    +    DialogKeysT keys;          // must be first (window userData)
    +    bool        done;
    +    bool        accepted;
    +    int32_t    *outVal;
    +    WidgetT    *spinner;
     } IntBoxStateT;
     
     typedef struct {
    -    bool     done;
    -    bool     accepted;
    -    int32_t *outIdx;
    -    WidgetT *listBox;
    +    DialogKeysT keys;          // must be first (window userData)
    +    bool        done;
    +    bool        accepted;
    +    int32_t    *outIdx;
    +    WidgetT    *listBox;
     } ChoiceBoxStateT;
     
     typedef struct {
    +    DialogKeysT      keys;           // must be first (window userData)
         AppContextT     *ctx;
         bool             done;           // set true to break modal loop
         bool             accepted;       // true if user clicked OK/Open/Save
    @@ -205,6 +231,8 @@ static void        cbOnCancel(WidgetT *w);
     static void        cbOnClose(WindowT *win);
     static void        cbOnDblClick(WidgetT *w);
     static void        cbOnOk(WidgetT *w);
    +static void        dialogCenter(AppContextT *ctx, WindowT *win);
    +static void        dialogOnKey(WindowT *win, int32_t key, int32_t mod);
     static void        drawIconCircle(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, uint32_t color);
     static void        drawIconGlyph(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t iconType, uint32_t color);
     static bool        fdAcceptFile(const char *name);
    @@ -266,7 +294,14 @@ static void cbOnOk(WidgetT *w) {
         // listbox failed to create, returning true would hand the caller
         // an unwritten *outIdx.
         if (sChoiceBox.listBox && sChoiceBox.outIdx) {
    -        *sChoiceBox.outIdx  = wgtListBoxGetSelected(sChoiceBox.listBox);
    +        int32_t sel = wgtListBoxGetSelected(sChoiceBox.listBox);
    +
    +        // No highlighted item: OK is a no-op, the dialog stays up.
    +        if (sel < 0) {
    +            return;
    +        }
    +
    +        *sChoiceBox.outIdx  = sel;
             sChoiceBox.accepted = true;
         }
     
    @@ -274,6 +309,70 @@ static void cbOnOk(WidgetT *w) {
     }
     
     
    +// Center a dialog's frame on the screen once its final size is known.
    +// dvxCreateWindow auto-cascades a window that lands on another's origin
    +// and dvxFitWindow changes the size after creation; both would leave a
    +// dialog off-center.
    +
    +static void dialogCenter(AppContextT *ctx, WindowT *win) {
    +    int32_t x = (ctx->display.width - win->w) / 2;
    +    int32_t y = (ctx->display.height - win->h) / 2;
    +
    +    if (x < 0) {
    +        x = 0;
    +    }
    +
    +    if (y < 0) {
    +        y = 0;
    +    }
    +
    +    dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    +    win->x = x;
    +    win->y = y;
    +    dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
    +}
    +
    +
    +// Window-level key hook installed on every dialog.  Escape activates
    +// the cancel button (the last button of a message box).  Enter runs the
    +// widget layer first, then activates the default button unless the
    +// focused widget is itself a button (it already pressed itself) or one
    +// of the dialog's Enter owners (path input, file list, ...).
    +
    +static void dialogOnKey(WindowT *win, int32_t key, int32_t mod) {
    +    DialogKeysT *keys  = (DialogKeysT *)win->userData;
    +    WidgetT     *focus = wgtGetFocused();
    +
    +    if (key == KEY_ESCAPE) {
    +        if (keys->cancelBtn && keys->cancelBtn->onClick) {
    +            keys->cancelBtn->onClick(keys->cancelBtn);
    +        }
    +
    +        return;
    +    }
    +
    +    widgetOnKey(win, key, mod);
    +
    +    if (key != KEY_ENTER || !keys->defaultBtn || !keys->defaultBtn->onClick) {
    +        return;
    +    }
    +
    +    if (focus) {
    +        if (focus->wclass == keys->defaultBtn->wclass) {
    +            return;
    +        }
    +
    +        for (int32_t i = 0; i < DLG_ENTER_OWNERS_MAX; i++) {
    +            if (keys->enterOwners[i] == focus) {
    +                return;
    +            }
    +        }
    +    }
    +
    +    keys->defaultBtn->onClick(keys->defaultBtn);
    +}
    +
    +
     // Draws the shared 2-pixel ring used by the circular message-box icons
     // (info, error, question) via the integer distance-squared test.
     static void drawIconCircle(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, uint32_t color) {
    @@ -370,14 +469,7 @@ bool dvxChoiceDialog(AppContextT *ctx, const char *title, const char *prompt, co
         int32_t contentH = CB_PADDING + promptH + CB_PADDING + CB_LIST_HEIGHT + CB_PADDING + BUTTON_HEIGHT + CB_PADDING;
         int32_t contentW = CB_DIALOG_WIDTH;
     
    -    int32_t winX = (ctx->display.width - contentW) / 2 - CHROME_TOTAL_SIDE;
    -    int32_t winY = (ctx->display.height - contentH) / 2 - CHROME_TOTAL_TOP;
    -
    -    WindowT *win = dvxCreateWindow(ctx, title ? title : "Choose",
    -                                   winX, winY,
    -                                   contentW + CHROME_TOTAL_SIDE * 2,
    -                                   contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM,
    -                                   false);
    +    WindowT *win = dvxCreateWindowCentered(ctx, title ? title : "Choose", contentW + CHROME_TOTAL_SIDE * 2, contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM, false);
     
         if (!win) {
             return false;
    @@ -388,13 +480,14 @@ bool dvxChoiceDialog(AppContextT *ctx, const char *title, const char *prompt, co
         win->maxW    = win->w;
         win->maxH    = win->h;
     
    -    sChoiceBox.done     = false;
    -    sChoiceBox.accepted = false;
    -    sChoiceBox.outIdx   = outIdx;
    -    sChoiceBox.listBox  = NULL;
    +    memset(&sChoiceBox, 0, sizeof(sChoiceBox));
    +    sChoiceBox.outIdx = outIdx;
     
         WidgetT *root = wgtInitWindow(ctx, win);
     
    +    win->userData = &sChoiceBox;
    +    win->onKey    = dialogOnKey;
    +
         if (root) {
             if (prompt && prompt[0]) {
                 wgtLabel(root, prompt);
    @@ -412,7 +505,8 @@ bool dvxChoiceDialog(AppContextT *ctx, const char *title, const char *prompt, co
                     wgtListBoxSetSelected(lb, defaultIdx);
                 }
     
    -            sChoiceBox.listBox = lb;
    +            sChoiceBox.listBox             = lb;
    +            sChoiceBox.keys.enterOwners[0] = lb;
             }
     
             WidgetT *btnRow = wgtHBox(root);
    @@ -423,22 +517,25 @@ bool dvxChoiceDialog(AppContextT *ctx, const char *title, const char *prompt, co
                 WidgetT *okBtn = wgtButton(btnRow, "&OK");
     
                 if (okBtn) {
    -                okBtn->minW    = wgtPixels(BUTTON_WIDTH);
    -                okBtn->minH    = wgtPixels(BUTTON_HEIGHT);
    -                okBtn->onClick = cbOnOk;
    +                okBtn->minW                = wgtPixels(BUTTON_WIDTH);
    +                okBtn->minH                = wgtPixels(BUTTON_HEIGHT);
    +                okBtn->onClick             = cbOnOk;
    +                sChoiceBox.keys.defaultBtn = okBtn;
                 }
     
                 WidgetT *cancelBtn = wgtButton(btnRow, "&Cancel");
     
                 if (cancelBtn) {
    -                cancelBtn->minW    = wgtPixels(BUTTON_WIDTH);
    -                cancelBtn->minH    = wgtPixels(BUTTON_HEIGHT);
    -                cancelBtn->onClick = cbOnCancel;
    +                cancelBtn->minW           = wgtPixels(BUTTON_WIDTH);
    +                cancelBtn->minH           = wgtPixels(BUTTON_HEIGHT);
    +                cancelBtn->onClick        = cbOnCancel;
    +                sChoiceBox.keys.cancelBtn = cancelBtn;
                 }
             }
         }
     
         dvxFitWindow(ctx, win);
    +    dialogCenter(ctx, win);
     
         WindowT *prevModal = ctx->modalWindow;
         ctx->modalWindow   = win;
    @@ -455,6 +552,35 @@ bool dvxChoiceDialog(AppContextT *ctx, const char *title, const char *prompt, co
     }
     
     
    +void dvxDialogTestReset(void) {
    +    fdFreeEntries();
    +    memset(&sMsgBox, 0, sizeof(sMsgBox));
    +    memset(&sInputBox, 0, sizeof(sInputBox));
    +    memset(&sIntBox, 0, sizeof(sIntBox));
    +    memset(&sChoiceBox, 0, sizeof(sChoiceBox));
    +    memset(&sFd, 0, sizeof(sFd));
    +}
    +
    +
    +// Creates and runs a modal message box. The flags parameter is a bitmask:
    +// low nibble selects button set (MB_OK, MB_YESNO, etc.), next nibble
    +// selects icon type (MB_ICONINFO, MB_ICONERROR, etc.). This is the same
    +// flag encoding Windows MessageBox uses, which makes porting code easier.
    +//
    +// The dialog is auto-sized to fit the word-wrapped message text plus the
    +// button row. Non-resizable (maxW/maxH clamped to initial size) because
    +// resizing a message box serves no purpose.
    +//
    +// Button labels use '&' to mark accelerator keys (e.g., "&OK" makes
    +// Alt+O activate the button). Button IDs are stored in widget->userData
    +// via intptr_t cast  -- a common pattern when you need to associate a
    +// small integer with a widget without allocating a separate struct.
    +
    +int32_t dvxErrorBox(AppContextT *ctx, const char *title, const char *message) {
    +    return dvxMessageBox(ctx, title ? title : "Error", message, MB_OK | MB_ICONERROR);
    +}
    +
    +
     // Creates a modal file dialog using the widget system. The layout is:
     //   - Path input (with Enter-to-navigate)
     //   - File listbox (single-click selects, double-click opens/accepts)
    @@ -493,12 +619,7 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
         sFd.curDir[DVX_MAX_PATH - 1] = '\0';
     
         // Create dialog window
    -    int32_t dlgW = FD_DIALOG_WIDTH;
    -    int32_t dlgH = FD_DIALOG_HEIGHT;
    -    int32_t winX = (ctx->display.width - dlgW) / 2;
    -    int32_t winY = (ctx->display.height - dlgH) / 2;
    -
    -    WindowT *win = dvxCreateWindow(ctx, title ? title : ((flags & FD_SAVE) ? "Save As" : "Open"), winX, winY, dlgW, dlgH, false);
    +    WindowT *win = dvxCreateWindowCentered(ctx, title ? title : ((flags & FD_SAVE) ? "Save As" : "Open"), FD_DIALOG_WIDTH, FD_DIALOG_HEIGHT, false);
     
         if (!win) {
             return false;
    @@ -514,6 +635,9 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
             return false;
         }
     
    +    win->userData = &sFd;
    +    win->onKey    = dialogOnKey;
    +
         // Path row
         WidgetT *pathRow = wgtHBox(root);
     
    @@ -527,6 +651,7 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
             // mid-edit (and stat() on each key).
             if (sFd.pathInput) {
                 sFd.pathInput->onKeyDown = fdOnPathKey;
    +            sFd.keys.enterOwners[0]  = sFd.pathInput;
             }
         }
     
    @@ -537,6 +662,7 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
             sFd.fileList->weight     = WGT_WEIGHT_FILL;
             sFd.fileList->onChange   = fdOnListClick;
             sFd.fileList->onDblClick = fdOnListDblClick;
    +        sFd.keys.enterOwners[1]  = sFd.fileList;
         }
     
         // Filter row (if filters provided)
    @@ -557,7 +683,8 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
     
                 wgtDropdownSetItems(sFd.filterDd, filterLabels, fc);
                 wgtDropdownSetSelected(sFd.filterDd, 0);
    -            sFd.filterDd->onChange = fdOnFilterChange;
    +            sFd.filterDd->onChange  = fdOnFilterChange;
    +            sFd.keys.enterOwners[2] = sFd.filterDd;
             }
         }
     
    @@ -578,8 +705,9 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
             WidgetT *okBtn = wgtButton(btnRow, (flags & FD_SAVE) ? "&Save" : "&Open");
     
             if (okBtn) {
    -            okBtn->onClick = fdOnOk;
    -            okBtn->minW    = wgtPixels(BUTTON_WIDTH);
    +            okBtn->onClick      = fdOnOk;
    +            okBtn->minW         = wgtPixels(BUTTON_WIDTH);
    +            sFd.keys.defaultBtn = okBtn;
             }
     
             WidgetT *cancelBtn = wgtButton(btnRow, "&Cancel");
    @@ -587,12 +715,14 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
             if (cancelBtn) {
                 cancelBtn->onClick = fdOnCancel;
                 cancelBtn->minW    = wgtPixels(BUTTON_WIDTH);
    +            sFd.keys.cancelBtn = cancelBtn;
             }
         }
     
         // Load initial directory
         fdLoadDir();
         wgtInvalidate(root);
    +    dialogCenter(ctx, win);
     
         // Modal loop.  Save/restore the previous modal so a file dialog
         // opened from inside another modal (e.g. a Browse button) does not
    @@ -634,6 +764,11 @@ bool dvxFileDialog(AppContextT *ctx, const char *title, int32_t flags, const cha
     }
     
     
    +int32_t dvxInfoBox(AppContextT *ctx, const char *title, const char *message) {
    +    return dvxMessageBox(ctx, title ? title : "Information", message, MB_OK | MB_ICONINFO);
    +}
    +
    +
     // Modal input dialog with prompt, text field, OK/Cancel buttons.
     // Follows the same nested-event-loop pattern as dvxMessageBox.
     
    @@ -652,14 +787,7 @@ bool dvxInputBox(AppContextT *ctx, const char *title, const char *prompt, const
         int32_t contentH = IB_PADDING + promptH + IB_PADDING + ctx->font.charHeight + 4 + IB_PADDING + BUTTON_HEIGHT + IB_PADDING;
         int32_t contentW = IB_DIALOG_WIDTH;
     
    -    int32_t winX = (ctx->display.width - contentW) / 2 - CHROME_TOTAL_SIDE;
    -    int32_t winY = (ctx->display.height - contentH) / 2 - CHROME_TOTAL_TOP;
    -
    -    WindowT *win = dvxCreateWindow(ctx, title ? title : "Input",
    -                                   winX, winY,
    -                                   contentW + CHROME_TOTAL_SIDE * 2,
    -                                   contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM,
    -                                   false);
    +    WindowT *win = dvxCreateWindowCentered(ctx, title ? title : "Input", contentW + CHROME_TOTAL_SIDE * 2, contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM, false);
     
         if (!win) {
             return false;
    @@ -670,14 +798,16 @@ bool dvxInputBox(AppContextT *ctx, const char *title, const char *prompt, const
         win->maxW    = win->w;
         win->maxH    = win->h;
     
    -    sInputBox.ctx       = ctx;
    -    sInputBox.done      = false;
    -    sInputBox.accepted  = false;
    -    sInputBox.outBuf    = outBuf;
    +    memset(&sInputBox, 0, sizeof(sInputBox));
    +    sInputBox.ctx        = ctx;
    +    sInputBox.outBuf     = outBuf;
         sInputBox.outBufSize = outBufSize;
     
         WidgetT *root = wgtInitWindow(ctx, win);
     
    +    win->userData = &sInputBox;
    +    win->onKey    = dialogOnKey;
    +
         if (root) {
             // Prompt label
             if (prompt && prompt[0]) {
    @@ -706,22 +836,25 @@ bool dvxInputBox(AppContextT *ctx, const char *title, const char *prompt, const
                 WidgetT *okBtn = wgtButton(btnRow, "&OK");
     
                 if (okBtn) {
    -                okBtn->minW    = wgtPixels(BUTTON_WIDTH);
    -                okBtn->minH    = wgtPixels(BUTTON_HEIGHT);
    -                okBtn->onClick = ibOnOk;
    +                okBtn->minW               = wgtPixels(BUTTON_WIDTH);
    +                okBtn->minH               = wgtPixels(BUTTON_HEIGHT);
    +                okBtn->onClick            = ibOnOk;
    +                sInputBox.keys.defaultBtn = okBtn;
                 }
     
                 WidgetT *cancelBtn = wgtButton(btnRow, "&Cancel");
     
                 if (cancelBtn) {
    -                cancelBtn->minW    = wgtPixels(BUTTON_WIDTH);
    -                cancelBtn->minH    = wgtPixels(BUTTON_HEIGHT);
    -                cancelBtn->onClick = ibOnCancel;
    +                cancelBtn->minW          = wgtPixels(BUTTON_WIDTH);
    +                cancelBtn->minH          = wgtPixels(BUTTON_HEIGHT);
    +                cancelBtn->onClick       = ibOnCancel;
    +                sInputBox.keys.cancelBtn = cancelBtn;
                 }
             }
         }
     
         dvxFitWindow(ctx, win);
    +    dialogCenter(ctx, win);
     
         WindowT *prevModal = ctx->modalWindow;
         ctx->modalWindow   = win;
    @@ -753,14 +886,7 @@ bool dvxIntInputBox(AppContextT *ctx, const char *title, const char *prompt, int
         int32_t contentH = IB_PADDING + promptH + IB_PADDING + ctx->font.charHeight + 8 + IB_PADDING + BUTTON_HEIGHT + IB_PADDING;
         int32_t contentW = IB_DIALOG_WIDTH;
     
    -    int32_t winX = (ctx->display.width - contentW) / 2 - CHROME_TOTAL_SIDE;
    -    int32_t winY = (ctx->display.height - contentH) / 2 - CHROME_TOTAL_TOP;
    -
    -    WindowT *win = dvxCreateWindow(ctx, title ? title : "Input",
    -                                   winX, winY,
    -                                   contentW + CHROME_TOTAL_SIDE * 2,
    -                                   contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM,
    -                                   false);
    +    WindowT *win = dvxCreateWindowCentered(ctx, title ? title : "Input", contentW + CHROME_TOTAL_SIDE * 2, contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM, false);
     
         if (!win) {
             return false;
    @@ -771,13 +897,14 @@ bool dvxIntInputBox(AppContextT *ctx, const char *title, const char *prompt, int
         win->maxW    = win->w;
         win->maxH    = win->h;
     
    -    sIntBox.done     = false;
    -    sIntBox.accepted = false;
    -    sIntBox.outVal   = outVal;
    -    sIntBox.spinner  = NULL;
    +    memset(&sIntBox, 0, sizeof(sIntBox));
    +    sIntBox.outVal = outVal;
     
         WidgetT *root = wgtInitWindow(ctx, win);
     
    +    win->userData = &sIntBox;
    +    win->onKey    = dialogOnKey;
    +
         if (root) {
             if (prompt && prompt[0]) {
                 wgtLabel(root, prompt);
    @@ -798,22 +925,25 @@ bool dvxIntInputBox(AppContextT *ctx, const char *title, const char *prompt, int
                 WidgetT *okBtn = wgtButton(btnRow, "&OK");
     
                 if (okBtn) {
    -                okBtn->minW    = wgtPixels(BUTTON_WIDTH);
    -                okBtn->minH    = wgtPixels(BUTTON_HEIGHT);
    -                okBtn->onClick = iibOnOk;
    +                okBtn->minW             = wgtPixels(BUTTON_WIDTH);
    +                okBtn->minH             = wgtPixels(BUTTON_HEIGHT);
    +                okBtn->onClick          = iibOnOk;
    +                sIntBox.keys.defaultBtn = okBtn;
                 }
     
                 WidgetT *cancelBtn = wgtButton(btnRow, "&Cancel");
     
                 if (cancelBtn) {
    -                cancelBtn->minW    = wgtPixels(BUTTON_WIDTH);
    -                cancelBtn->minH    = wgtPixels(BUTTON_HEIGHT);
    -                cancelBtn->onClick = iibOnCancel;
    +                cancelBtn->minW        = wgtPixels(BUTTON_WIDTH);
    +                cancelBtn->minH        = wgtPixels(BUTTON_HEIGHT);
    +                cancelBtn->onClick     = iibOnCancel;
    +                sIntBox.keys.cancelBtn = cancelBtn;
                 }
             }
         }
     
         dvxFitWindow(ctx, win);
    +    dialogCenter(ctx, win);
     
         WindowT *prevModal = ctx->modalWindow;
         ctx->modalWindow   = win;
    @@ -830,30 +960,6 @@ bool dvxIntInputBox(AppContextT *ctx, const char *title, const char *prompt, int
     }
     
     
    -// Creates and runs a modal message box. The flags parameter is a bitmask:
    -// low nibble selects button set (MB_OK, MB_YESNO, etc.), next nibble
    -// selects icon type (MB_ICONINFO, MB_ICONERROR, etc.). This is the same
    -// flag encoding Windows MessageBox uses, which makes porting code easier.
    -//
    -// The dialog is auto-sized to fit the word-wrapped message text plus the
    -// button row. Non-resizable (maxW/maxH clamped to initial size) because
    -// resizing a message box serves no purpose.
    -//
    -// Button labels use '&' to mark accelerator keys (e.g., "&OK" makes
    -// Alt+O activate the button). Button IDs are stored in widget->userData
    -// via intptr_t cast  -- a common pattern when you need to associate a
    -// small integer with a widget without allocating a separate struct.
    -
    -int32_t dvxErrorBox(AppContextT *ctx, const char *title, const char *message) {
    -    return dvxMessageBox(ctx, title ? title : "Error", message, MB_OK | MB_ICONERROR);
    -}
    -
    -
    -int32_t dvxInfoBox(AppContextT *ctx, const char *title, const char *message) {
    -    return dvxMessageBox(ctx, title ? title : "Information", message, MB_OK | MB_ICONINFO);
    -}
    -
    -
     int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message, int32_t flags) {
         if (!ctx || !message) {
             return ID_CANCEL;
    @@ -938,24 +1044,19 @@ int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message,
         int32_t contentH = msgAreaH + BUTTON_HEIGHT + MSG_PADDING * 3;
     
         // Create the dialog window (non-resizable)
    -    int32_t winX = (ctx->display.width - contentW) / 2 - CHROME_TOTAL_SIDE;
    -    int32_t winY = (ctx->display.height - contentH) / 2 - CHROME_TOTAL_TOP;
    -
    -    WindowT *win = dvxCreateWindow(ctx, title, winX, winY,
    -                                   contentW + CHROME_TOTAL_SIDE * 2,
    -                                   contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM,
    -                                   false);
    +    WindowT *win = dvxCreateWindowCentered(ctx, title, contentW + CHROME_TOTAL_SIDE * 2, contentH + CHROME_TOTAL_TOP + CHROME_TOTAL_BOTTOM, false);
     
         if (!win) {
             return ID_CANCEL;
         }
     
    +    dialogCenter(ctx, win);
         win->modal = true;
     
         // Set up state
    +    memset(&sMsgBox, 0, sizeof(sMsgBox));
         sMsgBox.ctx      = ctx;
         sMsgBox.result   = ID_CANCEL;
    -    sMsgBox.done     = false;
         sMsgBox.message  = message;
         sMsgBox.iconType = iconFlags;
         sMsgBox.textX    = MSG_PADDING + (hasIcon ? ICON_AREA_WIDTH : 0);
    @@ -970,6 +1071,7 @@ int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message,
         // Override onPaint with our custom handler, set window-level state
         win->userData = &sMsgBox;
         win->onPaint  = onMsgBoxPaint;
    +    win->onKey    = dialogOnKey;
         win->onClose  = onMsgBoxClose;
         win->maxW     = win->w;
         win->maxH     = win->h;
    @@ -996,6 +1098,13 @@ int32_t dvxMessageBox(AppContextT *ctx, const char *title, const char *message,
                         btn->minH     = wgtPixels(BUTTON_HEIGHT);
                         btn->userData = (void *)(intptr_t)btnIds[i];
                         btn->onClick  = onButtonClick;
    +
    +                    // Enter = first button, Escape = last (OK / Cancel / No)
    +                    if (i == 0) {
    +                        sMsgBox.keys.defaultBtn = btn;
    +                    }
    +
    +                    sMsgBox.keys.cancelBtn = btn;
                     }
                 }
             }
    @@ -1123,7 +1232,7 @@ static int fdEntryCompare(const void *a, const void *b) {
             return sFd.entryIsDir[ia] ? -1 : 1;
         }
     
    -    return stricmp(sFd.entryNames[ia], sFd.entryNames[ib]);
    +    return strcasecmp(sFd.entryNames[ia], sFd.entryNames[ib]);
     }
     
     
    @@ -1433,8 +1542,10 @@ static void fdNavigate(const char *path) {
             return;
         }
     
    -    // Canonicalize the path
    -    char canon[DVX_MAX_PATH];
    +    // Canonicalize the path.  realpath needs a PATH_MAX buffer (glibc's
    +    // fortified build aborts on anything smaller); the result is then
    +    // clipped into curDir.
    +    char canon[PATH_MAX];
     
         if (realpath(resolved, canon) != NULL) {
             strncpy(sFd.curDir, canon, DVX_MAX_PATH - 1);
    diff --git a/src/libs/kpunch/libdvx/dvxDraw.c b/src/libs/kpunch/libdvx/dvxDraw.c
    index dd52930..fb6a18d 100644
    --- a/src/libs/kpunch/libdvx/dvxDraw.c
    +++ b/src/libs/kpunch/libdvx/dvxDraw.c
    @@ -195,6 +195,27 @@ char accelParse(const char *text) {
     }
     
     
    +// ============================================================
    +// calcCenteredText
    +// ============================================================
    +//
    +// Shared by every widget that centers a text label in its own rect
    +// (buttons, checkbox labels, etc.).  Centralising the arithmetic
    +// avoids tiny off-by-one drift from copy-paste variations.
    +
    +void calcCenteredText(const BitmapFontT *font, int32_t rectX, int32_t rectY, int32_t rectW, int32_t rectH, const char *text, int32_t *outX, int32_t *outY) {
    +    int32_t textW = textWidthAccel(font, text);
    +
    +    if (outX) {
    +        *outX = rectX + (rectW - textW) / 2;
    +    }
    +
    +    if (outY) {
    +        *outY = rectY + (rectH - font->charHeight) / 2;
    +    }
    +}
    +
    +
     // ============================================================
     // clipRect
     // ============================================================
    @@ -633,65 +654,6 @@ void drawHLine(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w
     }
     
     
    -// ============================================================
    -// calcCenteredText
    -// ============================================================
    -//
    -// Shared by every widget that centers a text label in its own rect
    -// (buttons, checkbox labels, etc.).  Centralising the arithmetic
    -// avoids tiny off-by-one drift from copy-paste variations.
    -
    -void calcCenteredText(const BitmapFontT *font, int32_t rectX, int32_t rectY, int32_t rectW, int32_t rectH, const char *text, int32_t *outX, int32_t *outY) {
    -    int32_t textW = textWidthAccel(font, text);
    -
    -    if (outX) {
    -        *outX = rectX + (rectW - textW) / 2;
    -    }
    -
    -    if (outY) {
    -        *outY = rectY + (rectH - font->charHeight) / 2;
    -    }
    -}
    -
    -
    -// ============================================================
    -// drawPressableBevel
    -// ============================================================
    -//
    -// Standard 2px button / toggle bevel.  Swaps highlight and shadow when
    -// pressed to create the sunken look.  face fills the interior (pass 0
    -// to leave the underlying content alone, e.g. for transparent toggle
    -// buttons).
    -
    -void drawPressableBevel(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, bool pressed, uint32_t face, const ColorSchemeT *colors) {
    -    BevelStyleT bevel;
    -    bevel.highlight = pressed ? colors->windowShadow    : colors->windowHighlight;
    -    bevel.shadow    = pressed ? colors->windowHighlight : colors->windowShadow;
    -    bevel.face      = face;
    -    bevel.width     = 2;
    -    drawBevel(d, ops, x, y, w, h, &bevel);
    -}
    -
    -
    -// ============================================================
    -// drawWidgetTextAccel
    -// ============================================================
    -//
    -// Every text-bearing widget paints this same enabled/disabled branch.
    -// Centralising it keeps the embossed-disabled appearance consistent
    -// and removes ~5 lines of boilerplate per widget.
    -
    -void drawWidgetTextAccel(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, uint32_t fg, uint32_t bg, bool opaque, bool enabled, const ColorSchemeT *colors) {
    -    if (!enabled) {
    -        drawTextAccel(d, ops, font, x + 1, y + 1, text, colors->windowHighlight, 0, false);
    -        drawTextAccel(d, ops, font, x,     y,     text, colors->windowShadow,    0, false);
    -        return;
    -    }
    -
    -    drawTextAccel(d, ops, font, x, y, text, fg, bg, opaque);
    -}
    -
    -
     // ============================================================
     // drawInit
     // ============================================================
    @@ -839,6 +801,25 @@ void drawMaskedBitmap(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, in
     }
     
     
    +// ============================================================
    +// drawPressableBevel
    +// ============================================================
    +//
    +// Standard 2px button / toggle bevel.  Swaps highlight and shadow when
    +// pressed to create the sunken look.  face fills the interior (pass 0
    +// to leave the underlying content alone, e.g. for transparent toggle
    +// buttons).
    +
    +void drawPressableBevel(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, bool pressed, uint32_t face, const ColorSchemeT *colors) {
    +    BevelStyleT bevel;
    +    bevel.highlight = pressed ? colors->windowShadow    : colors->windowHighlight;
    +    bevel.shadow    = pressed ? colors->windowHighlight : colors->windowShadow;
    +    bevel.face      = face;
    +    bevel.width     = 2;
    +    drawBevel(d, ops, x, y, w, h, &bevel);
    +}
    +
    +
     // Draws a 1px rectangle outline (top/bottom HLine, left/right VLine) in a
     // single color.  Factors the repeated 4-line outline idiom.
     void drawRectOutline(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t w, int32_t h, uint32_t color) {
    @@ -1393,6 +1374,25 @@ void drawVLine(DisplayT *d, const BlitOpsT *ops, int32_t x, int32_t y, int32_t h
     }
     
     
    +// ============================================================
    +// drawWidgetTextAccel
    +// ============================================================
    +//
    +// Every text-bearing widget paints this same enabled/disabled branch.
    +// Centralising it keeps the embossed-disabled appearance consistent
    +// and removes ~5 lines of boilerplate per widget.
    +
    +void drawWidgetTextAccel(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, uint32_t fg, uint32_t bg, bool opaque, bool enabled, const ColorSchemeT *colors) {
    +    if (!enabled) {
    +        drawTextAccel(d, ops, font, x + 1, y + 1, text, colors->windowHighlight, 0, false);
    +        drawTextAccel(d, ops, font, x,     y,     text, colors->windowShadow,    0, false);
    +        return;
    +    }
    +
    +    drawTextAccel(d, ops, font, x, y, text, fg, bg, opaque);
    +}
    +
    +
     // ============================================================
     // putPixel
     // ============================================================
    diff --git a/src/libs/kpunch/libdvx/dvxMem.h b/src/libs/kpunch/libdvx/dvxMem.h
    index eac454f..b4aa436 100644
    --- a/src/libs/kpunch/libdvx/dvxMem.h
    +++ b/src/libs/kpunch/libdvx/dvxMem.h
    @@ -38,12 +38,12 @@
     
     extern int32_t *dvxMemAppIdPtr;
     
    -void    *dvxMalloc(size_t size);
     void    *dvxCalloc(size_t nmemb, size_t size);
    -void    *dvxRealloc(void *ptr, size_t size);
     void     dvxFree(void *ptr);
    -char    *dvxStrdup(const char *s);
    +void    *dvxMalloc(size_t size);
     uint32_t dvxMemGetAppUsage(int32_t appId);
     void     dvxMemResetApp(int32_t appId);
    +void    *dvxRealloc(void *ptr, size_t size);
    +char    *dvxStrdup(const char *s);
     
     #endif // DVX_MEM_H
    diff --git a/src/libs/kpunch/libdvx/dvxPal.h b/src/libs/kpunch/libdvx/dvxPal.h
    index 68b0351..758d572 100644
    --- a/src/libs/kpunch/libdvx/dvxPal.h
    +++ b/src/libs/kpunch/libdvx/dvxPal.h
    @@ -59,8 +59,7 @@
     #define PAL_CHROME_WHITE        239
     
     // Generate the default 8-bit palette into a 768-byte buffer (256 * 3, RGB)
    -static inline void dvxGeneratePalette(uint8_t *pal)
    -{
    +static inline void dvxGeneratePalette(uint8_t *pal) {
         int32_t idx = 0;
     
         // Entries 0-215: 6x6x6 color cube
    @@ -133,6 +132,7 @@ static inline void dvxGeneratePalette(uint8_t *pal)
         }
     }
     
    +
     // Find the nearest palette entry for an RGB color using minimum Euclidean
     // distance in RGB space. The two-phase approach avoids a full 256-entry
     // linear scan in the common case:
    @@ -142,8 +142,7 @@ static inline void dvxGeneratePalette(uint8_t *pal)
     //      (indices 216-239) to see if any is closer than the cube match.
     // Entries 240-255 are reserved (black) and skipped to avoid false matches.
     // This is called by packColor() in 8-bit mode, so it needs to be fast.
    -static inline uint8_t dvxNearestPalEntry(const uint8_t *pal, uint8_t r, uint8_t g, uint8_t b)
    -{
    +static inline uint8_t dvxNearestPalEntry(const uint8_t *pal, uint8_t r, uint8_t g, uint8_t b) {
         // Snap to nearest cube vertex: +25 rounds to nearest 51-step level
         int32_t ri = (r + 25) / 51;
         int32_t gi = (g + 25) / 51;
    diff --git a/src/libs/kpunch/libdvx/dvxPrefs.c b/src/libs/kpunch/libdvx/dvxPrefs.c
    index e56365a..869680a 100644
    --- a/src/libs/kpunch/libdvx/dvxPrefs.c
    +++ b/src/libs/kpunch/libdvx/dvxPrefs.c
    @@ -133,27 +133,6 @@ static void freeEntry(PrefsEntryT *e) {
     }
     
     
    -static int strcmpci(const char *a, const char *b) {
    -    for (;;) {
    -        int d = tolower((unsigned char)*a) - tolower((unsigned char)*b);
    -
    -        if (d != 0 || !*a) {
    -            return d;
    -        }
    -
    -        a++;
    -        b++;
    -    }
    -}
    -
    -
    -static char *trimInPlace(char *buf) {
    -    char *p = (char *)dvxSkipWs(buf);
    -    dvxTrimRight(p);
    -    return p;
    -}
    -
    -
     // ============================================================
     // Public API
     // ============================================================
    @@ -445,3 +424,25 @@ void prefsSetString(PrefsHandleT *h, const char *section, const char *key, const
         newEntry.value   = dupStr(value);
         arrins(h->entries, insertAt, newEntry);
     }
    +
    +
    +static int strcmpci(const char *a, const char *b) {
    +    for (;;) {
    +        int d = tolower((unsigned char)*a) - tolower((unsigned char)*b);
    +
    +        if (d != 0 || !*a) {
    +            return d;
    +        }
    +
    +        a++;
    +        b++;
    +    }
    +}
    +
    +
    +static char *trimInPlace(char *buf) {
    +    char *p = (char *)dvxSkipWs(buf);
    +    dvxTrimRight(p);
    +    return p;
    +}
    +
    diff --git a/src/libs/kpunch/libdvx/dvxWgt.h b/src/libs/kpunch/libdvx/dvxWgt.h
    index b2fb8c2..00c0e8c 100644
    --- a/src/libs/kpunch/libdvx/dvxWgt.h
    +++ b/src/libs/kpunch/libdvx/dvxWgt.h
    @@ -312,34 +312,6 @@ typedef struct WidgetT {
     
     } WidgetT;
     
    -// ============================================================
    -// Typed dispatch helpers
    -// ============================================================
    -//
    -// Each wclsFoo() inline function extracts a handler by stable method ID,
    -// casts it to the correct function pointer type, and calls it with a
    -// NULL check. This gives callers type-safe dispatch with the same
    -// codegen as a direct struct field call.
    -
    -static inline bool wclsHas(const WidgetT *w, int32_t methodId) {
    -    return w->wclass && w->wclass->handlers[methodId] != NULL;
    -}
    -
    -
    -static inline void wclsPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    -    typedef void (*FnT)(WidgetT *, DisplayT *, const BlitOpsT *, const BitmapFontT *, const ColorSchemeT *);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_PAINT] : NULL;
    -    if (fn) { fn(w, d, ops, font, colors); }
    -}
    -
    -
    -static inline void wclsPaintOverlay(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    -    typedef void (*FnT)(WidgetT *, DisplayT *, const BlitOpsT *, const BitmapFontT *, const ColorSchemeT *);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_PAINT_OVERLAY] : NULL;
    -    if (fn) { fn(w, d, ops, font, colors); }
    -}
    -
    -
     static inline void wclsCalcMinSize(WidgetT *w, const BitmapFontT *font) {
         typedef void (*FnT)(WidgetT *, const BitmapFontT *);
         FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_CALC_MIN_SIZE] : NULL;
    @@ -347,52 +319,20 @@ static inline void wclsCalcMinSize(WidgetT *w, const BitmapFontT *font) {
     }
     
     
    -static inline void wclsLayout(WidgetT *w, const BitmapFontT *font) {
    -    typedef void (*FnT)(WidgetT *, const BitmapFontT *);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_LAYOUT] : NULL;
    -    if (fn) { fn(w, font); }
    +static inline bool wclsClearSelection(WidgetT *w) {
    +    typedef bool (*FnT)(WidgetT *);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_CLEAR_SELECTION] : NULL;
    +    return fn ? fn(w) : false;
     }
     
     
    -static inline void wclsGetLayoutMetrics(const WidgetT *w, const BitmapFontT *font, int32_t *pad, int32_t *gap, int32_t *extraTop, int32_t *borderW) {
    -    typedef void (*FnT)(const WidgetT *, const BitmapFontT *, int32_t *, int32_t *, int32_t *, int32_t *);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_GET_LAYOUT_METRICS] : NULL;
    -    if (fn) { fn(w, font, pad, gap, extraTop, borderW); }
    -}
    -
    -
    -static inline void wclsOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
    -    typedef void (*FnT)(WidgetT *, WidgetT *, int32_t, int32_t);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_ON_MOUSE] : NULL;
    -    if (fn) { fn(w, root, vx, vy); }
    -}
    -
    -
    -static inline void wclsOnKey(WidgetT *w, int32_t key, int32_t mod) {
    -    typedef void (*FnT)(WidgetT *, int32_t, int32_t);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_ON_KEY] : NULL;
    -    if (fn) { fn(w, key, mod); }
    -}
    -
    -
    -// Dispatched on the widget LOSING keyboard focus, before the app-level
    -// onBlur callback.  Editing widgets (e.g. the spinner) commit/clamp their
    -// in-progress text here.  Must be called from every focus-loss transition
    -// (mouse focus change, Tab/accel navigation, window blur, wgtSetFocused).
    -static inline void wclsOnBlur(WidgetT *w) {
    +static inline void wclsClosePopup(WidgetT *w) {
         typedef void (*FnT)(WidgetT *);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_ON_BLUR] : NULL;
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_CLOSE_POPUP] : NULL;
         if (fn) { fn(w); }
     }
     
     
    -static inline void wclsOnAccelActivate(WidgetT *w, WidgetT *root) {
    -    typedef void (*FnT)(WidgetT *, WidgetT *);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_ON_ACCEL_ACTIVATE] : NULL;
    -    if (fn) { fn(w, root); }
    -}
    -
    -
     // Default destroy behavior: if the widget's class does not supply an
     // explicit WGT_METHOD_DESTROY handler, free w->data automatically (and
     // the text-at-offset-0 first, if WCLASS_HAS_TEXT is set). This removes
    @@ -416,38 +356,17 @@ static inline void wclsDestroy(WidgetT *w) {
     }
     
     
    -static inline void wclsOnChildChanged(WidgetT *parent, WidgetT *child) {
    -    typedef void (*FnT)(WidgetT *, WidgetT *);
    -    FnT fn = parent->wclass ? (FnT)parent->wclass->handlers[WGT_METHOD_ON_CHILD_CHANGED] : NULL;
    -    if (fn) { fn(parent, child); }
    +static inline int32_t wclsGetCursorShape(const WidgetT *w, int32_t vx, int32_t vy) {
    +    typedef int32_t (*FnT)(const WidgetT *, int32_t, int32_t);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_GET_CURSOR_SHAPE] : NULL;
    +    return fn ? fn(w, vx, vy) : 0;
     }
     
     
    -static inline const char *wclsGetText(const WidgetT *w) {
    -    typedef const char *(*FnT)(const WidgetT *);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_GET_TEXT] : NULL;
    -    return fn ? fn(w) : "";
    -}
    -
    -
    -static inline void wclsSetText(WidgetT *w, const char *text) {
    -    typedef void (*FnT)(WidgetT *, const char *);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_SET_TEXT] : NULL;
    -    if (fn) { fn(w, text); }
    -}
    -
    -
    -static inline bool wclsClearSelection(WidgetT *w) {
    -    typedef bool (*FnT)(WidgetT *);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_CLEAR_SELECTION] : NULL;
    -    return fn ? fn(w) : false;
    -}
    -
    -
    -static inline void wclsClosePopup(WidgetT *w) {
    -    typedef void (*FnT)(WidgetT *);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_CLOSE_POPUP] : NULL;
    -    if (fn) { fn(w); }
    +static inline void wclsGetLayoutMetrics(const WidgetT *w, const BitmapFontT *font, int32_t *pad, int32_t *gap, int32_t *extraTop, int32_t *borderW) {
    +    typedef void (*FnT)(const WidgetT *, const BitmapFontT *, int32_t *, int32_t *, int32_t *, int32_t *);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_GET_LAYOUT_METRICS] : NULL;
    +    if (fn) { fn(w, font, pad, gap, extraTop, borderW); }
     }
     
     
    @@ -458,10 +377,56 @@ static inline void wclsGetPopupRect(const WidgetT *w, const BitmapFontT *font, i
     }
     
     
    -static inline void wclsOnDragUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y) {
    -    typedef void (*FnT)(WidgetT *, WidgetT *, int32_t, int32_t);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_ON_DRAG_UPDATE] : NULL;
    -    if (fn) { fn(w, root, x, y); }
    +static inline const char *wclsGetText(const WidgetT *w) {
    +    typedef const char *(*FnT)(const WidgetT *);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_GET_TEXT] : NULL;
    +    return fn ? fn(w) : "";
    +}
    +
    +
    +// ============================================================
    +// Typed dispatch helpers
    +// ============================================================
    +//
    +// Each wclsFoo() inline function extracts a handler by stable method ID,
    +// casts it to the correct function pointer type, and calls it with a
    +// NULL check. This gives callers type-safe dispatch with the same
    +// codegen as a direct struct field call.
    +
    +static inline bool wclsHas(const WidgetT *w, int32_t methodId) {
    +    return w->wclass && w->wclass->handlers[methodId] != NULL;
    +}
    +
    +
    +static inline void wclsLayout(WidgetT *w, const BitmapFontT *font) {
    +    typedef void (*FnT)(WidgetT *, const BitmapFontT *);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_LAYOUT] : NULL;
    +    if (fn) { fn(w, font); }
    +}
    +
    +
    +static inline void wclsOnAccelActivate(WidgetT *w, WidgetT *root) {
    +    typedef void (*FnT)(WidgetT *, WidgetT *);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_ON_ACCEL_ACTIVATE] : NULL;
    +    if (fn) { fn(w, root); }
    +}
    +
    +
    +// Dispatched on the widget LOSING keyboard focus, before the app-level
    +// onBlur callback.  Editing widgets (e.g. the spinner) commit/clamp their
    +// in-progress text here.  Must be called from every focus-loss transition
    +// (mouse focus change, Tab/accel navigation, window blur, wgtSetFocused).
    +static inline void wclsOnBlur(WidgetT *w) {
    +    typedef void (*FnT)(WidgetT *);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_ON_BLUR] : NULL;
    +    if (fn) { fn(w); }
    +}
    +
    +
    +static inline void wclsOnChildChanged(WidgetT *parent, WidgetT *child) {
    +    typedef void (*FnT)(WidgetT *, WidgetT *);
    +    FnT fn = parent->wclass ? (FnT)parent->wclass->handlers[WGT_METHOD_ON_CHILD_CHANGED] : NULL;
    +    if (fn) { fn(parent, child); }
     }
     
     
    @@ -472,10 +437,38 @@ static inline void wclsOnDragEnd(WidgetT *w, WidgetT *root, int32_t x, int32_t y
     }
     
     
    -static inline int32_t wclsGetCursorShape(const WidgetT *w, int32_t vx, int32_t vy) {
    -    typedef int32_t (*FnT)(const WidgetT *, int32_t, int32_t);
    -    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_GET_CURSOR_SHAPE] : NULL;
    -    return fn ? fn(w, vx, vy) : 0;
    +static inline void wclsOnDragUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y) {
    +    typedef void (*FnT)(WidgetT *, WidgetT *, int32_t, int32_t);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_ON_DRAG_UPDATE] : NULL;
    +    if (fn) { fn(w, root, x, y); }
    +}
    +
    +
    +static inline void wclsOnKey(WidgetT *w, int32_t key, int32_t mod) {
    +    typedef void (*FnT)(WidgetT *, int32_t, int32_t);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_ON_KEY] : NULL;
    +    if (fn) { fn(w, key, mod); }
    +}
    +
    +
    +static inline void wclsOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy) {
    +    typedef void (*FnT)(WidgetT *, WidgetT *, int32_t, int32_t);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_ON_MOUSE] : NULL;
    +    if (fn) { fn(w, root, vx, vy); }
    +}
    +
    +
    +static inline void wclsPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    +    typedef void (*FnT)(WidgetT *, DisplayT *, const BlitOpsT *, const BitmapFontT *, const ColorSchemeT *);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_PAINT] : NULL;
    +    if (fn) { fn(w, d, ops, font, colors); }
    +}
    +
    +
    +static inline void wclsPaintOverlay(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors) {
    +    typedef void (*FnT)(WidgetT *, DisplayT *, const BlitOpsT *, const BitmapFontT *, const ColorSchemeT *);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_PAINT_OVERLAY] : NULL;
    +    if (fn) { fn(w, d, ops, font, colors); }
     }
     
     
    @@ -500,6 +493,13 @@ static inline void wclsScrollChildIntoView(WidgetT *parent, const WidgetT *child
     }
     
     
    +static inline void wclsSetText(WidgetT *w, const char *text) {
    +    typedef void (*FnT)(WidgetT *, const char *);
    +    FnT fn = w->wclass ? (FnT)w->wclass->handlers[WGT_METHOD_SET_TEXT] : NULL;
    +    if (fn) { fn(w, text); }
    +}
    +
    +
     // ============================================================
     // Window integration
     // ============================================================
    @@ -543,8 +543,8 @@ void wgtInvalidate(WidgetT *w);
     void wgtInvalidatePaint(WidgetT *w);
     
     // Set/get widget text (label, button, textInput, etc.)
    -void wgtSetText(WidgetT *w, const char *text);
     const char *wgtGetText(const WidgetT *w);
    +void wgtSetText(WidgetT *w, const char *text);
     
     // Enable/disable a widget
     void wgtSetEnabled(WidgetT *w, bool enabled);
    @@ -553,8 +553,8 @@ void wgtSetEnabled(WidgetT *w, bool enabled);
     void wgtSetReadOnly(WidgetT *w, bool readOnly);
     
     // Set/get keyboard focus.  wgtSetFocused refuses disabled or hidden widgets.
    -void wgtSetFocused(WidgetT *w);
     WidgetT *wgtGetFocused(void);
    +void wgtSetFocused(WidgetT *w);
     
     // Move keyboard focus to w (NULL clears it): clears the previous widget's
     // selection, repaints both, and fires the blur/focus callbacks.  Returns
    @@ -652,8 +652,8 @@ void wgtPaint(WidgetT *root, DisplayT *d, const BlitOpsT *ops, const BitmapFontT
     // This replaces the monolithic WidgetApiT -- adding a new widget
     // requires zero changes to dvxWidget.h.
     
    -void wgtRegisterApi(const char *name, const void *api);
     const void *wgtGetApi(const char *name);
    +void wgtRegisterApi(const char *name, const void *api);
     
     // ============================================================
     // Widget interface descriptors
    @@ -755,8 +755,8 @@ typedef struct {
     } WgtIfaceT;
     
     // Register/retrieve interface descriptors by widget type name.
    -void wgtRegisterIface(const char *name, const WgtIfaceT *iface);
     const WgtIfaceT *wgtGetIface(const char *name);
    +void wgtRegisterIface(const char *name, const WgtIfaceT *iface);
     
     // Case-insensitive lookup of a property descriptor on iface by name.
     // Single source of truth for the runtime and IDE property-override rule.
    @@ -768,8 +768,8 @@ const WgtPropDescT *wgtIfaceFindProp(const WgtIfaceT *iface, const char *propNam
     const char *wgtFindByBasName(const char *basName);
     
     // Enumerate all registered widget interfaces.
    -int32_t wgtIfaceCount(void);
     const WgtIfaceT *wgtIfaceAt(int32_t idx, const char **outName);
    +int32_t wgtIfaceCount(void);
     
     // Get/set the .wgt file path for a registered widget (set by loader).
     const char *wgtIfaceGetPath(const char *name);
    diff --git a/src/libs/kpunch/libdvx/dvxWgtP.h b/src/libs/kpunch/libdvx/dvxWgtP.h
    index 977d3e2..224466f 100644
    --- a/src/libs/kpunch/libdvx/dvxWgtP.h
    +++ b/src/libs/kpunch/libdvx/dvxWgtP.h
    @@ -94,23 +94,24 @@ static inline int32_t clampInt(int32_t val, int32_t lo, int32_t hi) {
     }
     
     
    +static inline void drawTextAccelEmbossed(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, const ColorSchemeT *colors) {
    +    drawTextAccel(d, ops, font, x + 1, y + 1, text, colors->windowHighlight, 0, false);
    +    drawTextAccel(d, ops, font, x, y, text, colors->windowShadow, 0, false);
    +}
    +
    +
     static inline void drawTextEmbossed(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, const ColorSchemeT *colors) {
         drawText(d, ops, font, x + 1, y + 1, text, colors->windowHighlight, 0, false);
         drawText(d, ops, font, x, y, text, colors->windowShadow, 0, false);
     }
     
     
    -static inline void drawTextAccelEmbossed(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, const ColorSchemeT *colors) {
    -    drawTextAccel(d, ops, font, x + 1, y + 1, text, colors->windowHighlight, 0, false);
    -    drawTextAccel(d, ops, font, x, y, text, colors->windowShadow, 0, false);
    -}
    -
     // ============================================================
     // Shared interaction state (defined in widgetCore.c)
     // ============================================================
     
     extern bool     sCursorBlinkOn;
    -extern clock_t  sDblClickTicks;
    +extern PlatformTicksT sDblClickTicks;
     extern bool     sDebugLayout;
     extern WidgetT *sClosedPopup;
     extern WidgetT *sFocusedWidget;
    @@ -125,14 +126,31 @@ extern WidgetT **sPollWidgets;    // stb_ds dynamic array
     extern uint32_t sWidgetGen;
     extern void   (*sCursorBlinkFn)(void);
     
    +// ============================================================
    +// Test-harness reset hooks
    +// ============================================================
    +//
    +// The host test runner brings the GUI stack up and down many times in
    +// one process.  dvxShutdown tears down the windows but never routes
    +// through widgetDetachWindowReferences, so every module-level pointer
    +// above (plus the per-module statics behind the helpers below) would
    +// dangle into the next dvxInit.  wgtTestReset returns all of it to its
    +// load-time state; the per-module helpers exist only because the
    +// statics they clear are file-local.  Never called on DOS.
    +void dvxAppTestReset(void);
    +void dvxDialogTestReset(void);
    +void wgtTestReset(void);
    +void widgetEventTestReset(void);
    +void widgetOpsTestReset(void);
    +
     // ============================================================
     // Core widget functions (widgetCore.c)
     // ============================================================
     
     // Tree manipulation
     void widgetAddChild(WidgetT *parent, WidgetT *child);
    -void widgetRemoveChild(WidgetT *parent, WidgetT *child);
     void widgetDestroyChildren(WidgetT *w);
    +void widgetRemoveChild(WidgetT *parent, WidgetT *child);
     
     // Clears every global interaction pointer that references w and removes w
     // from the poll list.  Call for every destroyed node.
    @@ -168,17 +186,17 @@ WidgetT *widgetAllocWithText(WidgetT *parent, int32_t type, size_t dataSize, con
     
     // Focus management.  widgetTransferFocus / widgetFireFocusChange are
     // declared in dvxWgt.h (the app layer's Tab/accelerator handlers use them).
    +WidgetT *widgetFindByAccel(WidgetT *root, char key);
     WidgetT *widgetFindNextFocusable(WidgetT *root, WidgetT *after);
     WidgetT *widgetFindPrevFocusable(WidgetT *root, WidgetT *before);
    -WidgetT *widgetFindByAccel(WidgetT *root, char key);
     
     // Utility queries
    +int32_t multiClickDetect(int32_t vx, int32_t vy);
     int32_t widgetCountVisibleChildren(const WidgetT *w);
     int32_t widgetFrameBorderWidth(const WidgetT *w);
     bool widgetIsFocusable(int32_t type);
     bool widgetIsHorizContainer(int32_t type);
     bool widgetIsShown(const WidgetT *w);
    -int32_t multiClickDetect(int32_t vx, int32_t vy);
     
     // Clipboard
     void clipboardCopy(const char *text, int32_t len);
    @@ -262,6 +280,17 @@ void widgetPressableOnDragUpdate(WidgetT *w, WidgetT *root, int32_t x, int32_t y
     void widgetPressableOnKey(WidgetT *w, int32_t key, int32_t mod);
     void widgetPressableOnMouse(WidgetT *w, WidgetT *root, int32_t vx, int32_t vy);
     
    +// ============================================================
    +// Change notification (widgetOps.c)
    +// ============================================================
    +//
    +// Fires w->onChange (when set) and reports whether the widget tree
    +// survived: false means the handler destroyed widgets (sWidgetGen moved)
    +// and w must not be touched again.  Widgets call this wherever code
    +// follows the notification.
    +
    +bool widgetFireChange(WidgetT *w);
    +
     // ============================================================
     // Text helpers (widgetOps.c)
     // ============================================================
    diff --git a/src/libs/kpunch/libdvx/dvxWm.c b/src/libs/kpunch/libdvx/dvxWm.c
    index 7bb6acb..1a3316c 100644
    --- a/src/libs/kpunch/libdvx/dvxWm.c
    +++ b/src/libs/kpunch/libdvx/dvxWm.c
    @@ -118,8 +118,8 @@ typedef struct {
     // Prototypes
     // ============================================================
     
    -static RectT clipSave(const DisplayT *d);
     static void clipRestore(DisplayT *d, const RectT *saved);
    +static RectT clipSave(const DisplayT *d);
     static void computeMenuBarPositions(WindowT *win, const BitmapFontT *font);
     static void computeTitleGeom(const WindowT *win, TitleGeomT *g);
     static void copyLabel(char *dst, const char *src, size_t cap);
    @@ -317,10 +317,6 @@ static void drawBorderFrame(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT
     // the bottom visually separates the menu bar from the content area.
     
     static void drawMenuBar(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, WindowT *win) {
    -    if (!win->menuBar) {
    -        return;
    -    }
    -
         int32_t barY = win->y + CHROME_TITLEBAR_BOTTOM;
         int32_t barH = CHROME_MENU_HEIGHT;
     
    @@ -835,91 +831,6 @@ static int32_t scrollbarThumbInfo(const ScrollbarT *sb, int32_t *thumbPos, int32
     }
     
     
    -// Renders a window-level scrollbar by delegating to the shared draw-layer
    -// painter.  Computes the screen position from the window origin plus the
    -// scrollbar's window-relative x/y, derives the thumb geometry from the
    -// scroll range, and hands everything to drawScrollbar().
    -//
    -// winX/winY are the window's screen position; sb->x/y are relative to the
    -// window origin. This split lets scrollbar positions survive window drags
    -// without recalculation  -- only winX/winY change.
    -
    -static void wmDrawScrollbar(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, const ScrollbarT *sb, int32_t winX, int32_t winY) {
    -    int32_t x = winX + sb->x;
    -    int32_t y = winY + sb->y;
    -
    -    int32_t thumbPos;
    -    int32_t thumbSize;
    -    scrollbarThumbInfo(sb, &thumbPos, &thumbSize);
    -
    -    drawScrollbar(d, ops, colors, sb->orient, x, y, sb->length, SCROLLBAR_WIDTH, thumbPos, thumbSize);
    -}
    -
    -
    -// Effective maximum frame size for a window: its maxW/maxH constraint
    -// clamped to the screen, or the full screen when WM_MAX_FROM_SCREEN.
    -// Shared by wmMaximize and wmResizeMove.
    -static void wmEffectiveMaxSize(const WindowT *win, const DisplayT *d, int32_t *maxW, int32_t *maxH) {
    -    *maxW = (win->maxW == WM_MAX_FROM_SCREEN) ? d->width : DVX_MIN(win->maxW, d->width);
    -    *maxH = (win->maxH == WM_MAX_FROM_SCREEN) ? d->height : DVX_MIN(win->maxH, d->height);
    -}
    -
    -
    -// wmMenuFindItem -- find a menu item by command ID anywhere in a menu bar,
    -// descending into nested submenus to any depth.  When outMenu is non-NULL it
    -// receives the menu that directly contains the matched item so callers can
    -// locate radio-group siblings and compute the item index.
    -
    -static MenuItemT *wmMenuFindItem(MenuBarT *bar, int32_t id, MenuT **outMenu) {
    -    if (!bar) {
    -        return NULL;
    -    }
    -
    -    for (int32_t m = 0; m < bar->menuCount; m++) {
    -        MenuItemT *found = wmMenuFindItemRecursive(bar->menus[m], id, outMenu);
    -
    -        if (found) {
    -            return found;
    -        }
    -    }
    -
    -    return NULL;
    -}
    -
    -
    -// wmMenuFindItemRecursive -- find a menu item by command ID within a single
    -// menu tree (the menu plus every nested submenu, to any depth).  When outMenu
    -// is non-NULL it receives the menu that directly contains the matched item.
    -// Shared id-search used by wmMenuFindItem (menu bars) and wmMenuFindItemInMenu
    -// (standalone popup/context menus).
    -
    -static MenuItemT *wmMenuFindItemRecursive(MenuT *menu, int32_t id, MenuT **outMenu) {
    -    if (!menu) {
    -        return NULL;
    -    }
    -
    -    for (int32_t i = 0; i < menu->itemCount; i++) {
    -        if (menu->items[i].id == id) {
    -            if (outMenu) {
    -                *outMenu = menu;
    -            }
    -
    -            return &menu->items[i];
    -        }
    -
    -        if (menu->items[i].subMenu) {
    -            MenuItemT *found = wmMenuFindItemRecursive(menu->items[i].subMenu, id, outMenu);
    -
    -            if (found) {
    -                return found;
    -            }
    -        }
    -    }
    -
    -    return NULL;
    -}
    -
    -
     // Adds a horizontal scrollbar to a window. The scrollbar steals
     // SCROLLBAR_WIDTH pixels from the bottom of the content area (handled by
     // wmUpdateContentRect). The caller specifies the logical scroll range; the
    @@ -1565,6 +1476,27 @@ void wmDrawMinimizedIcons(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *
     }
     
     
    +// Renders a window-level scrollbar by delegating to the shared draw-layer
    +// painter.  Computes the screen position from the window origin plus the
    +// scrollbar's window-relative x/y, derives the thumb geometry from the
    +// scroll range, and hands everything to drawScrollbar().
    +//
    +// winX/winY are the window's screen position; sb->x/y are relative to the
    +// window origin. This split lets scrollbar positions survive window drags
    +// without recalculation  -- only winX/winY change.
    +
    +static void wmDrawScrollbar(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, const ScrollbarT *sb, int32_t winX, int32_t winY) {
    +    int32_t x = winX + sb->x;
    +    int32_t y = winY + sb->y;
    +
    +    int32_t thumbPos;
    +    int32_t thumbSize;
    +    scrollbarThumbInfo(sb, &thumbPos, &thumbSize);
    +
    +    drawScrollbar(d, ops, colors, sb->orient, x, y, sb->length, SCROLLBAR_WIDTH, thumbPos, thumbSize);
    +}
    +
    +
     void wmDrawScrollbars(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, WindowT *win, const RectT *clipTo) {
         RectT savedClip = clipSave(d);
     
    @@ -1604,6 +1536,15 @@ void wmDrawVScrollbarAt(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *co
     }
     
     
    +// Effective maximum frame size for a window: its maxW/maxH constraint
    +// clamped to the screen, or the full screen when WM_MAX_FROM_SCREEN.
    +// Shared by wmMaximize and wmResizeMove.
    +static void wmEffectiveMaxSize(const WindowT *win, const DisplayT *d, int32_t *maxW, int32_t *maxH) {
    +    *maxW = (win->maxW == WM_MAX_FROM_SCREEN) ? d->width : DVX_MIN(win->maxW, d->width);
    +    *maxH = (win->maxH == WM_MAX_FROM_SCREEN) ? d->height : DVX_MIN(win->maxH, d->height);
    +}
    +
    +
     // Frees a standalone context menu and all its submenus recursively.
     // freeMenuRecursive tears down the submenu tree and item array; this also
     // frees the root MenuT itself, which the caller owns.
    @@ -1811,6 +1752,28 @@ void wmMaximize(WindowStackT *stack, DirtyListT *dl, const DisplayT *d, WindowT
     }
     
     
    +// wmMenuFindItem -- find a menu item by command ID anywhere in a menu bar,
    +// descending into nested submenus to any depth.  When outMenu is non-NULL it
    +// receives the menu that directly contains the matched item so callers can
    +// locate radio-group siblings and compute the item index.
    +
    +static MenuItemT *wmMenuFindItem(MenuBarT *bar, int32_t id, MenuT **outMenu) {
    +    if (!bar) {
    +        return NULL;
    +    }
    +
    +    for (int32_t m = 0; m < bar->menuCount; m++) {
    +        MenuItemT *found = wmMenuFindItemRecursive(bar->menus[m], id, outMenu);
    +
    +        if (found) {
    +            return found;
    +        }
    +    }
    +
    +    return NULL;
    +}
    +
    +
     // wmMenuFindItemInMenu -- recursively find an item by command ID within a
     // single menu tree (the menu itself plus every nested submenu).  Used for
     // popup/context menus that are not attached to a menu bar, so the menu-bar
    @@ -1820,6 +1783,39 @@ MenuItemT *wmMenuFindItemInMenu(MenuT *menu, int32_t id) {
     }
     
     
    +// wmMenuFindItemRecursive -- find a menu item by command ID within a single
    +// menu tree (the menu plus every nested submenu, to any depth).  When outMenu
    +// is non-NULL it receives the menu that directly contains the matched item.
    +// Shared id-search used by wmMenuFindItem (menu bars) and wmMenuFindItemInMenu
    +// (standalone popup/context menus).
    +
    +static MenuItemT *wmMenuFindItemRecursive(MenuT *menu, int32_t id, MenuT **outMenu) {
    +    if (!menu) {
    +        return NULL;
    +    }
    +
    +    for (int32_t i = 0; i < menu->itemCount; i++) {
    +        if (menu->items[i].id == id) {
    +            if (outMenu) {
    +                *outMenu = menu;
    +            }
    +
    +            return &menu->items[i];
    +        }
    +
    +        if (menu->items[i].subMenu) {
    +            MenuItemT *found = wmMenuFindItemRecursive(menu->items[i].subMenu, id, outMenu);
    +
    +            if (found) {
    +                return found;
    +            }
    +        }
    +    }
    +
    +    return NULL;
    +}
    +
    +
     bool wmMenuItemIsChecked(MenuBarT *bar, int32_t id) {
         MenuItemT *item = wmMenuFindItem(bar, id, NULL);
         return item ? item->checked : false;
    diff --git a/src/libs/kpunch/libdvx/platform/dvxMemTrack.c b/src/libs/kpunch/libdvx/platform/dvxMemTrack.c
    new file mode 100644
    index 0000000..1954ca8
    --- /dev/null
    +++ b/src/libs/kpunch/libdvx/platform/dvxMemTrack.c
    @@ -0,0 +1,255 @@
    +// 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.
    +
    +
    +// dvxMemTrack.c  -- per-app memory tracking shared by every platform backend
    +//
    +// Wraps malloc/free/calloc/realloc/strdup with a small header per
    +// allocation that records the owning app ID and size, so DVX can report
    +// per-app memory usage in the Task Manager and detect leaks at app
    +// termination.  Pure portable C: linked into dvx.exe on DOS (where the
    +// DXE export table maps libc's malloc family onto these wrappers) and into
    +// the host test libraries.
    +//
    +// The allocator reads *dvxMemAppIdPtr to determine which app to charge.
    +// The shell sets this pointer to &ctx->currentAppId during init.
    +//
    +// Cross-boundary safety: when dvxFree receives a pointer that was
    +// allocated by libc (not our wrapper), the magic check at ptr-16 fails
    +// and we fall through to libc free.  On DJGPP the heap is a contiguous
    +// sbrk region so reading 16 bytes before any heap pointer is always
    +// mapped memory.  On hosts with a sanitizer the same read is flagged
    +// only for untracked pointers, which DVX never hands to dvxFree.
    +
    +#include "dvxPlat.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +// Per-app memory tracking header magic
    +#define DVX_ALLOC_MAGIC 0xDEADBEEFUL
    +
    +// Growth slack when the per-app usage table has to expand for a new appId.
    +#define APP_MEM_GROW_SLACK 16
    +
    +
    +// Per-app memory tracking header prepended to each allocation
    +typedef struct {
    +    uint32_t magic;
    +    int32_t  appId;
    +    uint32_t size;
    +    uint32_t pad;
    +} DvxAllocHeaderT;
    +
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static void dvxMemGrow(int32_t appId);
    +static void memUncharge(int32_t appId, uint32_t size);
    +
    +
    +// ============================================================
    +// Module state
    +// ============================================================
    +
    +int32_t         *dvxMemAppIdPtr = NULL;
    +static uint32_t *sAppMemUsed    = NULL;
    +static int32_t   sAppMemCap     = 0;
    +
    +
    +void *dvxCalloc(size_t nmemb, size_t size) {
    +    // Guard against nmemb*size overflowing size_t (calloc contract).
    +    if (size != 0 && nmemb > SIZE_MAX / size) {
    +        return NULL;
    +    }
    +
    +    size_t total = nmemb * size;
    +    void *ptr    = dvxMalloc(total);
    +
    +    if (ptr) {
    +        memset(ptr, 0, total);
    +    }
    +
    +    return ptr;
    +}
    +
    +
    +void dvxFree(void *ptr) {
    +    if (!ptr) {
    +        return;
    +    }
    +
    +    DvxAllocHeaderT *hdr = (DvxAllocHeaderT *)ptr - 1;
    +
    +    if (hdr->magic != DVX_ALLOC_MAGIC) {
    +        // Not a tracked allocation -- pass through to real free
    +        free(ptr);
    +        return;
    +    }
    +
    +    int32_t appId = hdr->appId;
    +
    +    if (appId >= 0 && appId < sAppMemCap) {
    +        memUncharge(appId, hdr->size);
    +    }
    +
    +    hdr->magic = 0;
    +    free(hdr);
    +}
    +
    +
    +void *dvxMalloc(size_t size) {
    +    int32_t appId = dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
    +
    +    // Guard the header addition against size_t wrap, which would otherwise
    +    // hand back a non-NULL pointer over an undersized block.
    +    if (size > SIZE_MAX - sizeof(DvxAllocHeaderT)) {
    +        return NULL;
    +    }
    +
    +    DvxAllocHeaderT *hdr = (DvxAllocHeaderT *)malloc(sizeof(DvxAllocHeaderT) + size);
    +
    +    if (!hdr) {
    +        return NULL;
    +    }
    +
    +    hdr->magic = DVX_ALLOC_MAGIC;
    +    hdr->appId = appId;
    +    hdr->size  = (uint32_t)size;
    +    hdr->pad   = 0;
    +
    +    if (appId >= 0) {
    +        dvxMemGrow(appId);
    +
    +        if (appId < sAppMemCap) {
    +            sAppMemUsed[appId] += (uint32_t)size;
    +        }
    +    }
    +
    +    return hdr + 1;
    +}
    +
    +
    +uint32_t dvxMemGetAppUsage(int32_t appId) {
    +    if (appId < 0 || appId >= sAppMemCap) {
    +        return 0;
    +    }
    +
    +    return sAppMemUsed[appId];
    +}
    +
    +
    +static void dvxMemGrow(int32_t appId) {
    +    if (appId < sAppMemCap) {
    +        return;
    +    }
    +
    +    int32_t   newCap = appId + APP_MEM_GROW_SLACK;
    +    uint32_t *newArr = (uint32_t *)realloc(sAppMemUsed, newCap * sizeof(uint32_t));
    +
    +    if (!newArr) {
    +        return;
    +    }
    +
    +    memset(newArr + sAppMemCap, 0, (newCap - sAppMemCap) * sizeof(uint32_t));
    +    sAppMemUsed = newArr;
    +    sAppMemCap  = newCap;
    +}
    +
    +
    +void dvxMemResetApp(int32_t appId) {
    +    if (appId >= 0 && appId < sAppMemCap) {
    +        sAppMemUsed[appId] = 0;
    +    }
    +}
    +
    +
    +void *dvxRealloc(void *ptr, size_t size) {
    +    if (!ptr) {
    +        return dvxMalloc(size);
    +    }
    +
    +    if (size == 0) {
    +        dvxFree(ptr);
    +        return NULL;
    +    }
    +
    +    DvxAllocHeaderT *hdr = (DvxAllocHeaderT *)ptr - 1;
    +
    +    if (hdr->magic != DVX_ALLOC_MAGIC) {
    +        // Not tracked -- pass through to real realloc
    +        return realloc(ptr, size);
    +    }
    +
    +    // Guard the header addition against size_t wrap, which would otherwise
    +    // hand back a non-NULL pointer over an undersized block.
    +    if (size > SIZE_MAX - sizeof(DvxAllocHeaderT)) {
    +        return NULL;
    +    }
    +
    +    int32_t  appId   = hdr->appId;
    +    uint32_t oldSize = hdr->size;
    +
    +    DvxAllocHeaderT *newHdr = (DvxAllocHeaderT *)realloc(hdr, sizeof(DvxAllocHeaderT) + size);
    +
    +    if (!newHdr) {
    +        return NULL;
    +    }
    +
    +    if (appId >= 0 && appId < sAppMemCap) {
    +        memUncharge(appId, oldSize);
    +        sAppMemUsed[appId] += (uint32_t)size;
    +    }
    +
    +    newHdr->size = (uint32_t)size;
    +    return newHdr + 1;
    +}
    +
    +
    +char *dvxStrdup(const char *s) {
    +    if (!s) {
    +        return NULL;
    +    }
    +
    +    size_t len = strlen(s) + 1;
    +    char *dup  = (char *)dvxMalloc(len);
    +
    +    if (dup) {
    +        memcpy(dup, s, len);
    +    }
    +
    +    return dup;
    +}
    +
    +
    +// Subtract a freed block from an app's usage, clamping at zero so a block
    +// freed after dvxMemResetApp (which already zeroed the counter) cannot wrap.
    +static void memUncharge(int32_t appId, uint32_t size) {
    +    if (sAppMemUsed[appId] >= size) {
    +        sAppMemUsed[appId] -= size;
    +    } else {
    +        sAppMemUsed[appId] = 0;
    +    }
    +}
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlat.h b/src/libs/kpunch/libdvx/platform/dvxPlat.h
    index fa25249..cb44805 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlat.h
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlat.h
    @@ -26,9 +26,15 @@
     // interface. To port DVX to a new platform, implement a new
     // dvxPlatformXxx.c against this header.
     //
    -// Currently one implementation exists:
    +// Two implementations exist:
     //   dvxPlatformDos.c   -- DJGPP/DPMI: real VESA VBE, INT 33h mouse,
     //                        INT 16h keyboard, rep movsd/stosl asm spans
    +//   dvxPlatformHost.c  -- native Linux test backend: offscreen
    +//                        framebuffer, queue-driven input, fake clock
    +//
    +// Code shared by every backend lives in dvxPlatformUtil.c (logging,
    +// path/file helpers, glob matching, Alt scancode table) and
    +// dvxMemTrack.c (per-app allocation tracking).
     //
     // The abstraction covers five areas: video mode setup, framebuffer
     // flushing, optimized memory spans, mouse input, and keyboard input.
    @@ -69,9 +75,37 @@ typedef struct {
     // Logging
     // ============================================================
     
    -// Append a line to dvx.log. Lives in dvx.exe, exported to all modules.
    +// Append a line to the DVX log file.  Lives in dvxPlatformUtil.c, so it
    +// is linked into dvx.exe (and exported to all modules) as well as into
    +// every host-side tool and test binary.
     void dvxLog(const char *fmt, ...);
     
    +// Set the path dvxLog appends to.  The pointer is retained, not copied,
    +// so it must outlive every subsequent dvxLog call (a string literal is
    +// the intended argument).  Defaults to "dvx.log" in the working
    +// directory until this is called.
    +void platformSetLogPath(const char *path);
    +
    +// ============================================================
    +// Timing
    +// ============================================================
    +
    +// Monotonic tick counter for UI timing (double-click, tooltip delay,
    +// cursor blink, timer widgets).  Ticks advance at platformClockHz()
    +// per second.  On DOS this wraps clock()/CLOCKS_PER_SEC; on the host
    +// test backend it is a fake clock advanced only by hostClockAdvance()
    +// so timing-dependent behaviour is deterministic under test.
    +//
    +// Unsigned so that "now - then" is wrap-safe; always compare intervals
    +// via subtraction, never compare raw tick values with < or >.
    +typedef uint32_t PlatformTicksT;
    +
    +PlatformTicksT platformClock(void);
    +PlatformTicksT platformClockHz(void);
    +
    +// Convert a millisecond interval to ticks at the platform rate.
    +#define PLATFORM_MS_TO_TICKS(ms) ((PlatformTicksT)(((uint64_t)(ms) * platformClockHz()) / 1000))
    +
     // ============================================================
     // System lifecycle
     // ============================================================
    @@ -133,12 +167,12 @@ void platformFlushRect(const DisplayT *d, const RectT *r);
     // differ only in the byte count computation (count * bytesPerPixel).
     // drawInit() selects the right function pointers into BlitOpsT at startup.
     
    -void platformSpanFill8(uint8_t *dst, uint32_t color, int32_t count);
    -void platformSpanFill16(uint8_t *dst, uint32_t color, int32_t count);
    -void platformSpanFill32(uint8_t *dst, uint32_t color, int32_t count);
    -void platformSpanCopy8(uint8_t *dst, const uint8_t *src, int32_t count);
     void platformSpanCopy16(uint8_t *dst, const uint8_t *src, int32_t count);
     void platformSpanCopy32(uint8_t *dst, const uint8_t *src, int32_t count);
    +void platformSpanCopy8(uint8_t *dst, const uint8_t *src, int32_t count);
    +void platformSpanFill16(uint8_t *dst, uint32_t color, int32_t count);
    +void platformSpanFill32(uint8_t *dst, uint32_t color, int32_t count);
    +void platformSpanFill8(uint8_t *dst, uint32_t color, int32_t count);
     
     // ============================================================
     // Input  -- Mouse
    @@ -336,7 +370,12 @@ void dvxReadDirFree(char **entries);
     //   #define SOMEDIR "CONFIG" DVX_PATH_SEP "WPAPER"
     // DJGPP accepts both '/' and '\\' for fopen/stat/etc., so either would
     // work, but '\\' is what DOS users expect to see in displayed paths.
    +// Every other platform uses '/'.
    +#ifdef __DJGPP__
     #define DVX_PATH_SEP "\\"
    +#else
    +#define DVX_PATH_SEP "/"
    +#endif
     
     // Simple glob pattern matching for filenames. Case-insensitive.
     // Supports * (zero or more chars) and ? (one char).
    @@ -354,6 +393,18 @@ int32_t platformStripLineEndings(char *buf, int32_t len);
     // DXE module support
     // ============================================================
     
    +// Symbol-name decoration for dlsym().  DJGPP's COFF toolchain prefixes
    +// every C symbol with an underscore and its dlsym() expects the
    +// decorated name; ELF platforms use the bare name.  DVX_SYM("appMain")
    +// yields the correct string literal for the current platform.  Code that
    +// builds names at runtime (snprintf) uses DVX_SYM_PREFIX instead.
    +#ifdef __DJGPP__
    +#define DVX_SYM_PREFIX "_"
    +#else
    +#define DVX_SYM_PREFIX ""
    +#endif
    +#define DVX_SYM(name) DVX_SYM_PREFIX name
    +
     // Register platform and C runtime symbols with the dynamic module
     // loader so that DXE modules can resolve them at load time.  On DOS
     // this calls dlregsym() with the full DJGPP libc/libm/libgcc/platform
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatHost.h b/src/libs/kpunch/libdvx/platform/dvxPlatHost.h
    new file mode 100644
    index 0000000..399240f
    --- /dev/null
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatHost.h
    @@ -0,0 +1,100 @@
    +// 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.
    +
    +// dvxPlatHost.h  -- test-harness hooks into the native host platform backend
    +//
    +// dvxPlatformHost.c implements dvxPlat.h for native Linux with an offscreen
    +// framebuffer, queue-driven input and a fake clock.  Nothing in DVX proper
    +// calls the functions declared here; they are the injection points a test
    +// runner uses to feed input, control time and inspect output.
    +//
    +// Input model: hostMousePush/hostKeyPush append to queues that the
    +// platform poll functions drain one entry per call, so one pushed event
    +// corresponds to one event-loop frame.  When the mouse queue is empty
    +// platformMousePoll keeps reporting the last state, exactly like a real
    +// driver whose mouse is not moving.
    +#ifndef DVX_PLAT_HOST_H
    +#define DVX_PLAT_HOST_H
    +
    +#include "dvxTypes.h"
    +
    +// ============================================================
    +// Mouse
    +// ============================================================
    +
    +// Queue a mouse state sample.  buttons is the platform bitmask: bit 0
    +// left, bit 1 right, bit 2 middle.  Coordinates are clamped to the
    +// screen range set by platformMouseInit.
    +void hostMousePush(int32_t x, int32_t y, int32_t buttons);
    +
    +// Accumulate a wheel delta (positive = scroll down) for the next
    +// platformMouseWheelPoll.
    +void hostWheelPush(int32_t delta);
    +
    +// ============================================================
    +// Keyboard
    +// ============================================================
    +
    +// Queue a key press.  ascii is 0 for extended keys (arrows, F-keys),
    +// scancode is the PC set-1 make code, modifiers is the BIOS shift-state
    +// byte (bits 0-1 shift, bit 2 ctrl, bit 3 alt) that
    +// platformKeyboardGetModifiers will report from this point on.
    +void hostKeyPush(int32_t ascii, int32_t scancode, int32_t modifiers);
    +
    +// Queue a key release for platformKeyUpRead.
    +void hostKeyUpPush(int32_t scancode);
    +
    +// Set the modifier byte without queueing a key (e.g. to hold Shift down
    +// across a mouse drag).
    +void hostSetModifiers(int32_t modifiers);
    +
    +// ============================================================
    +// Clock
    +// ============================================================
    +
    +// Advance the fake clock by ms milliseconds.
    +void hostClockAdvance(int32_t ms);
    +
    +// Set the fake clock to an absolute millisecond value.
    +void hostClockSet(uint32_t ms);
    +
    +// Switch between the fake clock (default, deterministic) and wall time
    +// for interactive use.  Entering real-time mode continues from the
    +// current fake value; leaving it freezes at the current wall value.
    +void hostClockSetRealTime(bool realTime);
    +
    +// ============================================================
    +// Framebuffer
    +// ============================================================
    +
    +// The flushed display surface (what a monitor would show): pixels reach
    +// it only through platformFlushRect, so it reflects the compositor's
    +// dirty-rect output rather than the raw backbuffer.  Same pitch and
    +// pixel format as the DisplayT.
    +const uint8_t *hostFramebuffer(const DisplayT *d);
    +
    +// Write the flushed display surface of the most recently initialised
    +// display as an RGB PNG.  Returns false if no display is active or the
    +// file could not be written.
    +bool hostSavePng(const char *path);
    +
    +#endif // DVX_PLAT_HOST_H
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c b/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
    index 2a2f54f..62a2ad7 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
    @@ -92,9 +92,6 @@
     // RDTSC calibration: measure over this many BIOS timer ticks
     #define CLOCK_MEAS_TICKS 3
     
    -// Per-app memory tracking header magic
    -#define DVX_ALLOC_MAGIC 0xDEADBEEFUL
    -
     // Key-up detection ring buffer size
     #define KEYUP_BUF_SIZE 16
     
    @@ -153,20 +150,10 @@
     #define MOUSE_BUTTON_MASK     0x07
     
     
    -// Per-app memory tracking header prepended to each allocation
    -typedef struct {
    -    uint32_t magic;
    -    int32_t  appId;
    -    uint32_t size;
    -    uint32_t pad;
    -} DvxAllocHeaderT;
    -
    -
     // ============================================================
     // Prototypes
     // ============================================================
     
    -static void                    dvxMemGrow(int32_t appId);
     static uint32_t                estimateClockMhz(void);
     static int32_t                 findBestMode(int32_t requestedW, int32_t requestedH, int32_t preferredBpp, uint16_t *outMode, DisplayT *d);
     static void                    getModeInfo(uint16_t mode, DisplayT *d, int32_t *score, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
    @@ -227,47 +214,15 @@ static int32_t    sCurY           = 0;
     static MouseModeE sMouseMode      = MouseModeMickeyE;
     static int32_t    sPromoteCounter = 0;
     
    -// Alt+key scan code to ASCII lookup table (indexed by BIOS scan code).
    -// INT 16h returns these scan codes with ascii=0 for Alt+key combos.
    -// Using a 256-byte lookup table instead of a switch or if-chain because
    -// this is called on every keypress and the table fits in a single cache
    -// line cluster. The designated initializer syntax leaves all other
    -// entries as zero, which is the "no mapping" sentinel.
    -static const char sAltScanToAscii[256] = {
    -    // Alt+letters
    -    [0x10] = 'q', [0x11] = 'w', [0x12] = 'e', [0x13] = 'r',
    -    [0x14] = 't', [0x15] = 'y', [0x16] = 'u', [0x17] = 'i',
    -    [0x18] = 'o', [0x19] = 'p', [0x1E] = 'a', [0x1F] = 's',
    -    [0x20] = 'd', [0x21] = 'f', [0x22] = 'g', [0x23] = 'h',
    -    [0x24] = 'j', [0x25] = 'k', [0x26] = 'l', [0x2C] = 'z',
    -    [0x2D] = 'x', [0x2E] = 'c', [0x2F] = 'v', [0x30] = 'b',
    -    [0x31] = 'n', [0x32] = 'm',
    -    // Alt+digits
    -    [0x78] = '1', [0x79] = '2', [0x7A] = '3', [0x7B] = '4',
    -    [0x7C] = '5', [0x7D] = '6', [0x7E] = '7', [0x7F] = '8',
    -    [0x80] = '9', [0x81] = '0',
    -};
    -
     // System information static buffer
     static char    sSysInfoBuf[PLATFORM_SYSINFO_MAX];
     static int32_t sSysInfoPos = 0;
     
    -// Per-app memory tracking  -- tracks every allocation made by DXE code
    -// via a 16-byte header prepended to each allocation. The DXE export
    -// table maps malloc/free/calloc/realloc/strdup to these wrappers so
    -// all DXE code is transparently tracked without #define macros.
    -//
    -// stb_ds is also tracked: the loader overrides STBDS_REALLOC/FREE
    -// to call dvxRealloc/dvxFree before including stb_ds.h.
    -//
    -// Cross-boundary safety: when dvxFree receives a pointer that was
    -// allocated by libc (not our wrapper), the magic check at ptr-16
    -// fails and we fall through to libc free. The DJGPP heap is a
    -// contiguous sbrk region so reading 16 bytes before any heap
    -// pointer is always valid memory (never unmapped).
    -int32_t  *dvxMemAppIdPtr = NULL;
    -static uint32_t *sAppMemUsed = NULL;
    -static int32_t   sAppMemCap  = 0;
    +// Per-app memory tracking (dvxMalloc & co.) lives in dvxMemTrack.c.  The
    +// DXE export table below maps malloc/free/calloc/realloc/strdup to those
    +// wrappers so all DXE code is transparently tracked without #define
    +// macros.  stb_ds is also tracked: the loader overrides STBDS_REALLOC/
    +// FREE to call dvxRealloc/dvxFree before including stb_ds.h.
     
     // Key-up detection via INT 9 hook.  The BIOS keyboard interrupt (INT 16h)
     // only reports key presses.  To detect key releases we chain INT 9 (the
    @@ -306,171 +261,6 @@ static uint32_t sLfbLinearAddr = LFB_NOT_MAPPED;
     static uint32_t sLfbMappedSize = 0;
     
     
    -void *dvxCalloc(size_t nmemb, size_t size) {
    -    // Guard against nmemb*size overflowing size_t (calloc contract).
    -    if (size != 0 && nmemb > SIZE_MAX / size) {
    -        return NULL;
    -    }
    -
    -    size_t total = nmemb * size;
    -    void *ptr    = dvxMalloc(total);
    -
    -    if (ptr) {
    -        memset(ptr, 0, total);
    -    }
    -
    -    return ptr;
    -}
    -
    -
    -void dvxFree(void *ptr) {
    -    if (!ptr) {
    -        return;
    -    }
    -
    -    DvxAllocHeaderT *hdr = (DvxAllocHeaderT *)ptr - 1;
    -
    -    if (hdr->magic != DVX_ALLOC_MAGIC) {
    -        // Not a tracked allocation -- pass through to real free
    -        free(ptr);
    -        return;
    -    }
    -
    -    int32_t appId = hdr->appId;
    -
    -    if (appId >= 0 && appId < sAppMemCap) {
    -        sAppMemUsed[appId] -= hdr->size;
    -    }
    -
    -    hdr->magic = 0;
    -    free(hdr);
    -}
    -
    -
    -void *dvxMalloc(size_t size) {
    -    int32_t appId = dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
    -
    -    // Guard the header addition against size_t wrap, which would otherwise
    -    // hand back a non-NULL pointer over an undersized block.
    -    if (size > SIZE_MAX - sizeof(DvxAllocHeaderT)) {
    -        return NULL;
    -    }
    -
    -    DvxAllocHeaderT *hdr = (DvxAllocHeaderT *)malloc(sizeof(DvxAllocHeaderT) + size);
    -
    -    if (!hdr) {
    -        return NULL;
    -    }
    -
    -    hdr->magic = DVX_ALLOC_MAGIC;
    -    hdr->appId = appId;
    -    hdr->size  = (uint32_t)size;
    -    hdr->pad   = 0;
    -
    -    if (appId >= 0) {
    -        dvxMemGrow(appId);
    -
    -        if (appId < sAppMemCap) {
    -            sAppMemUsed[appId] += (uint32_t)size;
    -        }
    -    }
    -
    -    return hdr + 1;
    -}
    -
    -
    -uint32_t dvxMemGetAppUsage(int32_t appId) {
    -    if (appId < 0 || appId >= sAppMemCap) {
    -        return 0;
    -    }
    -
    -    return sAppMemUsed[appId];
    -}
    -
    -
    -static void dvxMemGrow(int32_t appId) {
    -    if (appId < sAppMemCap) {
    -        return;
    -    }
    -
    -    int32_t newCap = appId + 16;
    -    uint32_t *newArr = (uint32_t *)realloc(sAppMemUsed, newCap * sizeof(uint32_t));
    -
    -    if (!newArr) {
    -        return;
    -    }
    -
    -    memset(newArr + sAppMemCap, 0, (newCap - sAppMemCap) * sizeof(uint32_t));
    -    sAppMemUsed = newArr;
    -    sAppMemCap  = newCap;
    -}
    -
    -
    -void dvxMemResetApp(int32_t appId) {
    -    if (appId >= 0 && appId < sAppMemCap) {
    -        sAppMemUsed[appId] = 0;
    -    }
    -}
    -
    -
    -void *dvxRealloc(void *ptr, size_t size) {
    -    if (!ptr) {
    -        return dvxMalloc(size);
    -    }
    -
    -    if (size == 0) {
    -        dvxFree(ptr);
    -        return NULL;
    -    }
    -
    -    DvxAllocHeaderT *hdr = (DvxAllocHeaderT *)ptr - 1;
    -
    -    if (hdr->magic != DVX_ALLOC_MAGIC) {
    -        // Not tracked -- pass through to real realloc
    -        return realloc(ptr, size);
    -    }
    -
    -    // Guard the header addition against size_t wrap, which would otherwise
    -    // hand back a non-NULL pointer over an undersized block.
    -    if (size > SIZE_MAX - sizeof(DvxAllocHeaderT)) {
    -        return NULL;
    -    }
    -
    -    int32_t  appId   = hdr->appId;
    -    uint32_t oldSize = hdr->size;
    -
    -    DvxAllocHeaderT *newHdr = (DvxAllocHeaderT *)realloc(hdr, sizeof(DvxAllocHeaderT) + size);
    -
    -    if (!newHdr) {
    -        return NULL;
    -    }
    -
    -    if (appId >= 0 && appId < sAppMemCap) {
    -        sAppMemUsed[appId] -= oldSize;
    -        sAppMemUsed[appId] += (uint32_t)size;
    -    }
    -
    -    newHdr->size = (uint32_t)size;
    -    return newHdr + 1;
    -}
    -
    -
    -char *dvxStrdup(const char *s) {
    -    if (!s) {
    -        return NULL;
    -    }
    -
    -    size_t len = strlen(s) + 1;
    -    char *dup  = (char *)dvxMalloc(len);
    -
    -    if (dup) {
    -        memcpy(dup, s, len);
    -    }
    -
    -    return dup;
    -}
    -
    -
     // RDTSC calibration via BIOS timer.
     // Measures TSC ticks over 3 BIOS timer ticks (~165 ms).  The BIOS timer
     // at 0040:006C increments at 18.2065 Hz (1193182 / 65536 Hz per tick).
    @@ -921,12 +711,16 @@ static int32_t mapLfb(DisplayT *d, uint32_t physAddr) {
     }
     
     
    -char platformAltScanToChar(int32_t scancode) {
    -    if (scancode < 0 || scancode > 255) {
    -        return 0;
    -    }
    +// UI tick source.  DJGPP's clock() is driven by the PIT (CLOCKS_PER_SEC
    +// is typically 91) and counts wall time, which is exactly what UI timing
    +// wants.  glibc's clock() would be CPU time -- hence the seam.
    +PlatformTicksT platformClock(void) {
    +    return (PlatformTicksT)clock();
    +}
     
    -    return sAltScanToAscii[scancode];
    +
    +PlatformTicksT platformClockHz(void) {
    +    return (PlatformTicksT)CLOCKS_PER_SEC;
     }
     
     
    @@ -2519,33 +2313,30 @@ extern unsigned char __dj_ctype_toupper[];
     extern void *__emutls_get_address(void *);
     
     // stb_ds internals (implementation compiled into dvx.exe via loaderMain.c)
    -// dvxLog lives in dvx.exe (loaderMain.c)
    -extern void dvxLog(const char *fmt, ...);
    -
     // DPMI/go32 internals needed by the serial driver (rs232)
     #include 
     #include 
     
    -extern void  *stbds_arrgrowf(void *a, size_t elemsize, size_t addlen, size_t min_cap);
     extern void   stbds_arrfreef(void *a);
    +extern void  *stbds_arrgrowf(void *a, size_t elemsize, size_t addlen, size_t min_cap);
    +extern size_t stbds_hash_bytes(void *p, size_t len, size_t seed);
    +extern size_t stbds_hash_string(char *str, size_t seed);
    +extern void  *stbds_hmdel_key(void *a, size_t elemsize, void *key, size_t keysize, size_t keyoffset, int mode);
     extern void   stbds_hmfree_func(void *a, size_t elemsize);
     extern void  *stbds_hmget_key(void *a, size_t elemsize, void *key, size_t keysize, int mode);
     extern void  *stbds_hmget_key_ts(void *a, size_t elemsize, void *key, size_t keysize, ptrdiff_t *temp, int mode);
     extern void  *stbds_hmput_default(void *a, size_t elemsize);
     extern void  *stbds_hmput_key(void *a, size_t elemsize, void *key, size_t keysize, int mode);
    -extern void  *stbds_hmdel_key(void *a, size_t elemsize, void *key, size_t keysize, size_t keyoffset, int mode);
    -extern void  *stbds_shmode_func(size_t elemsize, int mode);
    -extern size_t stbds_hash_string(char *str, size_t seed);
    -extern size_t stbds_hash_bytes(void *p, size_t len, size_t seed);
     extern void   stbds_rand_seed(size_t seed);
    +extern void  *stbds_shmode_func(size_t elemsize, int mode);
     extern char  *stbds_stralloc(void *a, char *str);
     extern void   stbds_strreset(void *a);
     
     // GCC runtime helpers (64-bit math, float conversion)
    -extern unsigned long long __fixunsdfdi(double);
    -extern unsigned long long __fixunssfdi(float);
     extern long long __fixdfdi(double);
     extern long long __fixsfdi(float);
    +extern unsigned long long __fixunsdfdi(double);
    +extern unsigned long long __fixunssfdi(float);
     extern double __floatdidf(long long);
     extern float __floatdisf(long long);
     
    @@ -2571,6 +2362,8 @@ DXE_EXPORT_TABLE(sDxeExportTable)
         // --- platform ---
         DXE_EXPORT(platformAltScanToChar)
         DXE_EXPORT(platformChdir)
    +    DXE_EXPORT(platformClock)
    +    DXE_EXPORT(platformClockHz)
         DXE_EXPORT(platformCopyFile)
         DXE_EXPORT(platformFlushRect)
         DXE_EXPORT(platformGetMemoryInfo)
    @@ -2598,6 +2391,7 @@ DXE_EXPORT_TABLE(sDxeExportTable)
         DXE_EXPORT(platformReadFile)
         DXE_EXPORT(platformRegisterDxeExports)
         DXE_EXPORT(platformRegisterSymOverrides)
    +    DXE_EXPORT(platformSetLogPath)
         DXE_EXPORT(platformSpanCopy8)
         DXE_EXPORT(platformSpanCopy16)
         DXE_EXPORT(platformSpanCopy32)
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c b/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
    new file mode 100644
    index 0000000..ef23243
    --- /dev/null
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
    @@ -0,0 +1,857 @@
    +// 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.
    +
    +// dvxPlatformHost.c  -- native Linux platform backend for DVX GUI
    +//
    +// Implements dvxPlat.h without any display, mouse or keyboard hardware so
    +// the whole GUI stack can run inside an ordinary process under the test
    +// harness (and sanitizers).  The five platform domains map to:
    +//   1. Video:    an offscreen backbuffer plus a separate "LFB" shadow that
    +//                only platformFlushRect writes, so tests observe exactly
    +//                what the compositor's dirty-rect logic flushed.
    +//   2. Flush:    memcpy per scanline.
    +//   3. Spans:    plain C fills/copies (16/32-bit fills replicate the value).
    +//   4. Mouse:    a queue fed by hostMousePush; one sample per poll.
    +//   5. Keyboard: queues fed by hostKeyPush/hostKeyUpPush; the modifier
    +//                byte is whatever the harness last set.
    +//
    +// Time comes from a fake clock (platformClock) that advances only when
    +// the harness says so, which makes double-click, tooltip, blink and timer
    +// behaviour deterministic.  hostClockSetRealTime switches to wall time
    +// for interactive runs.
    +//
    +// Everything test-facing is declared in dvxPlatHost.h; everything DVX
    +// proper sees is the dvxPlat.h contract.
    +
    +#include "dvxPlat.h"
    +#include "dvxPlatHost.h"
    +#include "dvxPal.h"
    +
    +#include "thirdparty/stb_image_write_wrap.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +// Default mode when the caller passes zero for a dimension.
    +#define HOST_DEFAULT_W       640
    +#define HOST_DEFAULT_H       480
    +#define HOST_DEFAULT_BPP     32
    +
    +// Bits per pixel for each of the three supported formats.
    +#define HOST_BPP_8           8
    +#define HOST_BPP_16          16
    +#define HOST_BPP_32          32
    +
    +// 8-bit palette: 256 entries of 3 bytes (RGB) each.
    +#define PALETTE_ENTRIES      256
    +#define PALETTE_BYTES        (PALETTE_ENTRIES * 3)
    +
    +// Fake clock resolution: one tick per millisecond.
    +#define HOST_CLOCK_HZ        1000
    +#define MS_PER_SEC           1000
    +#define NS_PER_MS            1000000
    +
    +// Input queue depths.  A frame consumes one mouse sample and every
    +// queued key, so these only need to absorb what a test pushes between
    +// two dvxUpdate calls.
    +#define MOUSE_QUEUE_SIZE     256
    +#define KEY_QUEUE_SIZE       256
    +
    +// Mouse button bitmask (bits 0-2 = L/R/M), matching the DOS backend.
    +#define MOUSE_BUTTON_MASK    0x07
    +
    +// BIOS shift-state byte: only the low 8 bits are meaningful.
    +#define MODIFIER_MASK        0xFF
    +
    +// PNG output is 3 bytes per pixel (RGB).
    +#define PNG_COMPONENTS       3
    +
    +// Kilobytes per byte divisor for platformGetMemoryInfo.
    +#define BYTES_PER_KB         1024
    +
    +// Sentinel for "clock not yet started" in real-time mode.
    +#define WALL_CLOCK_UNSET     0
    +
    +
    +// Ring buffer of mouse state samples.
    +typedef struct {
    +    int32_t x;
    +    int32_t y;
    +    int32_t buttons;
    +} HostMouseSampleT;
    +
    +
    +// Fixed-size FIFO used for both the mouse and keyboard queues.
    +typedef struct {
    +    int32_t head;
    +    int32_t tail;
    +    int32_t capacity;
    +} HostQueueT;
    +
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static void     clampMouse(int32_t *x, int32_t *y);
    +static void     crashHandler(int sig);
    +static void     fillFormat(DisplayT *d, int32_t bpp);
    +static bool     queueIsEmpty(const HostQueueT *q);
    +static bool     queueIsFull(const HostQueueT *q);
    +static int32_t  queuePop(HostQueueT *q);
    +static int32_t  queuePush(HostQueueT *q);
    +static uint32_t wallClockMs(void);
    +
    +
    +// ============================================================
    +// Module state
    +// ============================================================
    +
    +// Fixed mode list reported by platformVideoEnumModes.
    +static const int32_t sModeList[][2] = {
    +    { 640,  480 },
    +    { 800,  600 },
    +    { 1024, 768 }
    +};
    +
    +static const int32_t sBppList[] = { HOST_BPP_8, HOST_BPP_16, HOST_BPP_32 };
    +
    +// Most recently initialised display, for hostSavePng.
    +static DisplayT *sDisplay = NULL;
    +
    +// Palette as last programmed by platformVideoSetPalette (8-bit modes).
    +static uint8_t sPalette[PALETTE_BYTES];
    +
    +// Fake clock.
    +static uint32_t sTicks         = 0;
    +static bool     sRealTime      = false;
    +static uint32_t sRealTimeBase  = WALL_CLOCK_UNSET;   // wall ms when real-time mode began
    +static uint32_t sRealTimeStart = 0;                  // fake ticks when real-time mode began
    +
    +// Mouse.
    +static HostMouseSampleT sMouseQueue[MOUSE_QUEUE_SIZE];
    +static HostQueueT       sMouseQ       = { 0, 0, MOUSE_QUEUE_SIZE };
    +static HostMouseSampleT sMouseState   = { 0, 0, 0 };
    +static int32_t          sMouseRangeW  = HOST_DEFAULT_W;
    +static int32_t          sMouseRangeH  = HOST_DEFAULT_H;
    +static int32_t          sWheelDelta   = 0;
    +
    +// Keyboard.
    +static PlatformKeyEventT sKeyQueue[KEY_QUEUE_SIZE];
    +static HostQueueT        sKeyQ       = { 0, 0, KEY_QUEUE_SIZE };
    +static PlatformKeyEventT sKeyUpQueue[KEY_QUEUE_SIZE];
    +static HostQueueT        sKeyUpQ     = { 0, 0, KEY_QUEUE_SIZE };
    +static int32_t           sModifiers  = 0;
    +
    +// Crash recovery.
    +static jmp_buf       *sCrashJmp    = NULL;
    +static volatile int  *sCrashSignal = NULL;
    +static PlatformLogFnT sCrashLogFn  = NULL;
    +
    +// System information static buffer.
    +static char sSysInfoBuf[PLATFORM_SYSINFO_MAX];
    +
    +
    +// ============================================================
    +// Internal helpers
    +// ============================================================
    +
    +static void clampMouse(int32_t *x, int32_t *y) {
    +    if (*x < 0) {
    +        *x = 0;
    +    }
    +
    +    if (*y < 0) {
    +        *y = 0;
    +    }
    +
    +    if (*x >= sMouseRangeW) {
    +        *x = sMouseRangeW - 1;
    +    }
    +
    +    if (*y >= sMouseRangeH) {
    +        *y = sMouseRangeH - 1;
    +    }
    +}
    +
    +
    +static void crashHandler(int sig) {
    +    if (sCrashLogFn) {
    +        platformLogCrashDetail(sig, sCrashLogFn);
    +    }
    +
    +    if (sCrashSignal) {
    +        *sCrashSignal = sig;
    +    }
    +
    +    if (sCrashJmp) {
    +        longjmp(*sCrashJmp, 1);
    +    }
    +
    +    // No recovery point: fall back to the default action.
    +    signal(sig, SIG_DFL);
    +    raise(sig);
    +}
    +
    +
    +// Fill in the PixelFormatT for one of the three supported depths.
    +static void fillFormat(DisplayT *d, int32_t bpp) {
    +    PixelFormatT *f = &d->format;
    +
    +    memset(f, 0, sizeof(*f));
    +    f->bitsPerPixel  = bpp;
    +    f->bytesPerPixel = bpp / 8;
    +
    +    switch (bpp) {
    +        case HOST_BPP_16:
    +            f->redBits    = 5;
    +            f->greenBits  = 6;
    +            f->blueBits   = 5;
    +            f->redShift   = 11;
    +            f->greenShift = 5;
    +            f->blueShift  = 0;
    +            break;
    +
    +        case HOST_BPP_32:
    +            f->redBits    = 8;
    +            f->greenBits  = 8;
    +            f->blueBits   = 8;
    +            f->redShift   = 16;
    +            f->greenShift = 8;
    +            f->blueShift  = 0;
    +            break;
    +
    +        default:
    +            // 8-bit palette mode: no colour fields.
    +            break;
    +    }
    +
    +    f->redMask   = ((1U << f->redBits) - 1) << f->redShift;
    +    f->greenMask = ((1U << f->greenBits) - 1) << f->greenShift;
    +    f->blueMask  = ((1U << f->blueBits) - 1) << f->blueShift;
    +}
    +
    +
    +void hostClockAdvance(int32_t ms) {
    +    sTicks += (uint32_t)ms;
    +}
    +
    +
    +void hostClockSet(uint32_t ms) {
    +    sTicks = ms;
    +}
    +
    +
    +void hostClockSetRealTime(bool realTime) {
    +    if (realTime == sRealTime) {
    +        return;
    +    }
    +
    +    if (realTime) {
    +        sRealTimeBase  = wallClockMs();
    +        sRealTimeStart = sTicks;
    +    } else {
    +        sTicks = platformClock();
    +    }
    +
    +    sRealTime = realTime;
    +}
    +
    +
    +const uint8_t *hostFramebuffer(const DisplayT *d) {
    +    return d->lfb;
    +}
    +
    +
    +void hostKeyPush(int32_t ascii, int32_t scancode, int32_t modifiers) {
    +    sModifiers = modifiers & MODIFIER_MASK;
    +
    +    int32_t idx = queuePush(&sKeyQ);
    +
    +    if (idx < 0) {
    +        return;
    +    }
    +
    +    sKeyQueue[idx].ascii    = ascii;
    +    sKeyQueue[idx].scancode = scancode;
    +}
    +
    +
    +void hostKeyUpPush(int32_t scancode) {
    +    int32_t idx = queuePush(&sKeyUpQ);
    +
    +    if (idx < 0) {
    +        return;
    +    }
    +
    +    sKeyUpQueue[idx].ascii    = 0;
    +    sKeyUpQueue[idx].scancode = scancode;
    +}
    +
    +
    +void hostMousePush(int32_t x, int32_t y, int32_t buttons) {
    +    int32_t idx = queuePush(&sMouseQ);
    +
    +    if (idx < 0) {
    +        return;
    +    }
    +
    +    clampMouse(&x, &y);
    +    sMouseQueue[idx].x       = x;
    +    sMouseQueue[idx].y       = y;
    +    sMouseQueue[idx].buttons = buttons & MOUSE_BUTTON_MASK;
    +}
    +
    +
    +bool hostSavePng(const char *path) {
    +    const DisplayT *d = sDisplay;
    +
    +    if (!d || !d->lfb || !path) {
    +        return false;
    +    }
    +
    +    size_t   rowBytes = (size_t)d->width * PNG_COMPONENTS;
    +    uint8_t *rgb      = (uint8_t *)malloc(rowBytes * (size_t)d->height);
    +
    +    if (!rgb) {
    +        return false;
    +    }
    +
    +    const PixelFormatT *f = &d->format;
    +
    +    for (int32_t y = 0; y < d->height; y++) {
    +        const uint8_t *src = d->lfb + (size_t)y * (size_t)d->pitch;
    +        uint8_t       *dst = rgb + (size_t)y * rowBytes;
    +
    +        for (int32_t x = 0; x < d->width; x++) {
    +            uint32_t pixel;
    +
    +            switch (f->bytesPerPixel) {
    +                case 1:
    +                    pixel = src[x];
    +                    break;
    +                case 2:
    +                    pixel = ((const uint16_t *)src)[x];
    +                    break;
    +                default:
    +                    pixel = ((const uint32_t *)src)[x];
    +                    break;
    +            }
    +
    +            if (f->bitsPerPixel == HOST_BPP_8) {
    +                const uint8_t *entry = sPalette + (pixel & (PALETTE_ENTRIES - 1)) * 3;
    +
    +                dst[0] = entry[0];
    +                dst[1] = entry[1];
    +                dst[2] = entry[2];
    +            } else {
    +                uint32_t r = (pixel & f->redMask)   >> f->redShift;
    +                uint32_t g = (pixel & f->greenMask) >> f->greenShift;
    +                uint32_t b = (pixel & f->blueMask)  >> f->blueShift;
    +
    +                // Scale each field up to 8 bits by replicating its top bits.
    +                dst[0] = (uint8_t)((r << (8 - f->redBits))   | (r >> (2 * f->redBits - 8)));
    +                dst[1] = (uint8_t)((g << (8 - f->greenBits)) | (g >> (2 * f->greenBits - 8)));
    +                dst[2] = (uint8_t)((b << (8 - f->blueBits))  | (b >> (2 * f->blueBits - 8)));
    +            }
    +
    +            dst += PNG_COMPONENTS;
    +        }
    +    }
    +
    +    bool ok = stbi_write_png(path, d->width, d->height, PNG_COMPONENTS, rgb, (int)rowBytes) != 0;
    +
    +    free(rgb);
    +    return ok;
    +}
    +
    +
    +void hostSetModifiers(int32_t modifiers) {
    +    sModifiers = modifiers & MODIFIER_MASK;
    +}
    +
    +
    +void hostWheelPush(int32_t delta) {
    +    sWheelDelta += delta;
    +}
    +
    +
    +PlatformTicksT platformClock(void) {
    +    if (sRealTime) {
    +        return sRealTimeStart + (wallClockMs() - sRealTimeBase);
    +    }
    +
    +    return sTicks;
    +}
    +
    +
    +PlatformTicksT platformClockHz(void) {
    +    return HOST_CLOCK_HZ;
    +}
    +
    +
    +void platformFlushRect(const DisplayT *d, const RectT *r) {
    +    if (r->w <= 0 || r->h <= 0) {
    +        return;
    +    }
    +
    +    int32_t        bpp      = d->format.bytesPerPixel;
    +    size_t         rowBytes = (size_t)r->w * (size_t)bpp;
    +    size_t         offset   = (size_t)r->y * (size_t)d->pitch + (size_t)r->x * (size_t)bpp;
    +    const uint8_t *src      = d->backBuf + offset;
    +    uint8_t       *dst      = d->lfb + offset;
    +
    +    for (int32_t y = 0; y < r->h; y++) {
    +        memcpy(dst, src, rowBytes);
    +        src += d->pitch;
    +        dst += d->pitch;
    +    }
    +}
    +
    +
    +bool platformGetMemoryInfo(uint32_t *totalKb, uint32_t *freeKb) {
    +    struct sysinfo si;
    +
    +    if (sysinfo(&si) != 0) {
    +        *totalKb = 0;
    +        *freeKb  = 0;
    +        return false;
    +    }
    +
    +    *totalKb = (uint32_t)(((uint64_t)si.totalram * si.mem_unit) / BYTES_PER_KB);
    +    *freeKb  = (uint32_t)(((uint64_t)si.freeram  * si.mem_unit) / BYTES_PER_KB);
    +    return true;
    +}
    +
    +
    +const char *platformGetSystemInfo(const DisplayT *display) {
    +    uint32_t totalKb = 0;
    +    uint32_t freeKb  = 0;
    +
    +    platformGetMemoryInfo(&totalKb, &freeKb);
    +
    +    snprintf(sSysInfoBuf, sizeof(sSysInfoBuf),
    +             "=== Platform ===\n"
    +             "Native host test backend\n"
    +             "\n"
    +             "=== Memory ===\n"
    +             "Total: %lu KB\n"
    +             "Free:  %lu KB\n"
    +             "\n"
    +             "=== Video ===\n"
    +             "Offscreen %ldx%ld, %ld bpp\n",
    +             (unsigned long)totalKb, (unsigned long)freeKb,
    +             (long)display->width, (long)display->height, (long)display->format.bitsPerPixel);
    +
    +    return sSysInfoBuf;
    +}
    +
    +
    +void platformInit(void) {
    +    // Nothing to set up: no console modes or break handling on the host.
    +}
    +
    +
    +void platformInstallCrashHandler(jmp_buf *recoveryBuf, volatile int *crashSignal, PlatformLogFnT logFn) {
    +    struct sigaction sa;
    +
    +    sCrashJmp    = recoveryBuf;
    +    sCrashSignal = crashSignal;
    +    sCrashLogFn  = logFn;
    +
    +    memset(&sa, 0, sizeof(sa));
    +    sa.sa_handler = crashHandler;
    +    sigemptyset(&sa.sa_mask);
    +    // SA_NODEFER keeps the signal unblocked after we longjmp out of the
    +    // handler, so a second crash is still caught.
    +    sa.sa_flags = SA_NODEFER;
    +
    +    sigaction(SIGSEGV, &sa, NULL);
    +    sigaction(SIGFPE,  &sa, NULL);
    +    sigaction(SIGILL,  &sa, NULL);
    +    sigaction(SIGBUS,  &sa, NULL);
    +}
    +
    +
    +int32_t platformKeyboardGetModifiers(void) {
    +    return sModifiers;
    +}
    +
    +
    +bool platformKeyboardRead(PlatformKeyEventT *evt) {
    +    if (queueIsEmpty(&sKeyQ)) {
    +        return false;
    +    }
    +
    +    *evt = sKeyQueue[queuePop(&sKeyQ)];
    +    return true;
    +}
    +
    +
    +void platformKeyUpInit(void) {
    +    sKeyUpQ.head = 0;
    +    sKeyUpQ.tail = 0;
    +}
    +
    +
    +bool platformKeyUpRead(PlatformKeyEventT *evt) {
    +    if (queueIsEmpty(&sKeyUpQ)) {
    +        return false;
    +    }
    +
    +    *evt = sKeyUpQueue[queuePop(&sKeyUpQ)];
    +    return true;
    +}
    +
    +
    +void platformKeyUpShutdown(void) {
    +    sKeyUpQ.head = 0;
    +    sKeyUpQ.tail = 0;
    +}
    +
    +
    +const char *platformLineEnding(void) {
    +    return "\n";
    +}
    +
    +
    +void platformLogCrashDetail(int sig, PlatformLogFnT logFn) {
    +    const char *sigName = "UNKNOWN";
    +
    +    if (sig == SIGSEGV) {
    +        sigName = "SIGSEGV (segmentation fault)";
    +    } else if (sig == SIGFPE) {
    +        sigName = "SIGFPE (floating point exception)";
    +    } else if (sig == SIGILL) {
    +        sigName = "SIGILL (illegal instruction)";
    +    } else if (sig == SIGBUS) {
    +        sigName = "SIGBUS (bus error)";
    +    }
    +
    +    logFn("=== CRASH ===");
    +    logFn("Signal: %d (%s)", sig, sigName);
    +}
    +
    +
    +void platformMouseInit(int32_t screenW, int32_t screenH) {
    +    sMouseRangeW        = screenW;
    +    sMouseRangeH        = screenH;
    +    sMouseState.x       = screenW / 2;
    +    sMouseState.y       = screenH / 2;
    +    sMouseState.buttons = 0;
    +    sMouseQ.head        = 0;
    +    sMouseQ.tail        = 0;
    +    sWheelDelta         = 0;
    +}
    +
    +
    +void platformMousePoll(int32_t *mx, int32_t *my, int32_t *buttons) {
    +    if (!queueIsEmpty(&sMouseQ)) {
    +        sMouseState = sMouseQueue[queuePop(&sMouseQ)];
    +    }
    +
    +    *mx      = sMouseState.x;
    +    *my      = sMouseState.y;
    +    *buttons = sMouseState.buttons;
    +}
    +
    +
    +void platformMouseSetAccel(int32_t threshold) {
    +    (void)threshold;
    +}
    +
    +
    +void platformMouseSetMickeys(int32_t horizMickeys, int32_t vertMickeys) {
    +    (void)horizMickeys;
    +    (void)vertMickeys;
    +}
    +
    +
    +void platformMouseWarp(int32_t x, int32_t y) {
    +    clampMouse(&x, &y);
    +    sMouseState.x = x;
    +    sMouseState.y = y;
    +}
    +
    +
    +bool platformMouseWheelInit(void) {
    +    return true;
    +}
    +
    +
    +int32_t platformMouseWheelPoll(void) {
    +    int32_t delta = sWheelDelta;
    +
    +    sWheelDelta = 0;
    +    return delta;
    +}
    +
    +
    +void platformRegisterDxeExports(void) {
    +    // ELF: symbols resolve through the executable's dynamic table
    +    // (-rdynamic) and RTLD_GLOBAL, no registration needed.
    +}
    +
    +
    +void platformRegisterSymOverrides(const PlatformSymOverrideT *entries) {
    +    (void)entries;
    +}
    +
    +
    +void platformSpanCopy16(uint8_t *dst, const uint8_t *src, int32_t count) {
    +    memcpy(dst, src, (size_t)count * 2);
    +}
    +
    +
    +void platformSpanCopy32(uint8_t *dst, const uint8_t *src, int32_t count) {
    +    memcpy(dst, src, (size_t)count * 4);
    +}
    +
    +
    +void platformSpanCopy8(uint8_t *dst, const uint8_t *src, int32_t count) {
    +    memcpy(dst, src, (size_t)count);
    +}
    +
    +
    +void platformSpanFill16(uint8_t *dst, uint32_t color, int32_t count) {
    +    uint16_t *p = (uint16_t *)dst;
    +    uint16_t  c = (uint16_t)color;
    +
    +    for (int32_t i = 0; i < count; i++) {
    +        p[i] = c;
    +    }
    +}
    +
    +
    +void platformSpanFill32(uint8_t *dst, uint32_t color, int32_t count) {
    +    uint32_t *p = (uint32_t *)dst;
    +
    +    for (int32_t i = 0; i < count; i++) {
    +        p[i] = color;
    +    }
    +}
    +
    +
    +void platformSpanFill8(uint8_t *dst, uint32_t color, int32_t count) {
    +    memset(dst, (int)(color & 0xFF), (size_t)count);
    +}
    +
    +
    +void platformSplashFillRect(int32_t x, int32_t y, int32_t w, int32_t h, uint8_t color) {
    +    (void)x;
    +    (void)y;
    +    (void)w;
    +    (void)h;
    +    (void)color;
    +}
    +
    +
    +void platformSplashInit(void) {
    +}
    +
    +
    +bool platformSplashLoadRaw(const char *path) {
    +    (void)path;
    +    return false;
    +}
    +
    +
    +void platformSplashShutdown(void) {
    +}
    +
    +
    +// Host filesystems accept nearly anything; keep the checks that matter
    +// for a name typed into a save dialog: non-empty, no directory separators,
    +// no control characters, and not "." or "..".
    +const char *platformValidateFilename(const char *name) {
    +    if (!name || name[0] == '\0') {
    +        return "Filename must not be empty.";
    +    }
    +
    +    if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) {
    +        return "That name is reserved.";
    +    }
    +
    +    for (const char *p = name; *p; p++) {
    +        if (*p == '/' || *p == '\\') {
    +            return "Filename must not contain a directory separator.";
    +        }
    +
    +        if (iscntrl((unsigned char)*p)) {
    +            return "Filename contains invalid characters.";
    +        }
    +    }
    +
    +    return NULL;
    +}
    +
    +
    +void platformVideoEnumModes(void (*cb)(int32_t w, int32_t h, int32_t bpp, void *userData), void *userData) {
    +    int32_t modeCount = (int32_t)(sizeof(sModeList) / sizeof(sModeList[0]));
    +    int32_t bppCount  = (int32_t)(sizeof(sBppList) / sizeof(sBppList[0]));
    +
    +    for (int32_t m = 0; m < modeCount; m++) {
    +        for (int32_t b = 0; b < bppCount; b++) {
    +            cb(sModeList[m][0], sModeList[m][1], sBppList[b], userData);
    +        }
    +    }
    +}
    +
    +
    +void platformVideoFreeBuffers(DisplayT *d) {
    +    free(d->backBuf);
    +    d->backBuf = NULL;
    +    free(d->palette);
    +    d->palette = NULL;
    +    free(d->lfb);
    +    d->lfb = NULL;
    +}
    +
    +
    +int32_t platformVideoInit(DisplayT *d, int32_t requestedW, int32_t requestedH, int32_t preferredBpp) {
    +    int32_t bpp = preferredBpp;
    +
    +    memset(d, 0, sizeof(*d));
    +
    +    if (bpp != HOST_BPP_8 && bpp != HOST_BPP_16 && bpp != HOST_BPP_32) {
    +        bpp = HOST_DEFAULT_BPP;
    +    }
    +
    +    d->width  = requestedW > 0 ? requestedW : HOST_DEFAULT_W;
    +    d->height = requestedH > 0 ? requestedH : HOST_DEFAULT_H;
    +    fillFormat(d, bpp);
    +    d->pitch = d->width * d->format.bytesPerPixel;
    +
    +    size_t fbSize = (size_t)d->pitch * (size_t)d->height;
    +
    +    d->backBuf = (uint8_t *)calloc(1, fbSize);
    +    d->lfb     = (uint8_t *)calloc(1, fbSize);
    +
    +    if (!d->backBuf || !d->lfb) {
    +        platformVideoFreeBuffers(d);
    +        return -1;
    +    }
    +
    +    if (bpp == HOST_BPP_8) {
    +        d->palette = (uint8_t *)malloc(PALETTE_BYTES);
    +
    +        if (!d->palette) {
    +            platformVideoFreeBuffers(d);
    +            return -1;
    +        }
    +
    +        dvxGeneratePalette(d->palette);
    +        platformVideoSetPalette(d->palette, 0, PALETTE_ENTRIES);
    +    }
    +
    +    d->clipX = 0;
    +    d->clipY = 0;
    +    d->clipW = d->width;
    +    d->clipH = d->height;
    +
    +    sDisplay = d;
    +    return 0;
    +}
    +
    +
    +void platformVideoSetPalette(const uint8_t *pal, int32_t firstEntry, int32_t count) {
    +    for (int32_t i = 0; i < count; i++) {
    +        int32_t entry = firstEntry + i;
    +
    +        if (entry < 0 || entry >= PALETTE_ENTRIES) {
    +            continue;
    +        }
    +
    +        memcpy(sPalette + entry * 3, pal + entry * 3, 3);
    +    }
    +}
    +
    +
    +void platformVideoShutdown(DisplayT *d) {
    +    platformVideoFreeBuffers(d);
    +
    +    if (sDisplay == d) {
    +        sDisplay = NULL;
    +    }
    +}
    +
    +
    +void platformYield(void) {
    +    // Nothing else is running in a test process.
    +}
    +
    +
    +static bool queueIsEmpty(const HostQueueT *q) {
    +    return q->head == q->tail;
    +}
    +
    +
    +static bool queueIsFull(const HostQueueT *q) {
    +    return ((q->head + 1) % q->capacity) == q->tail;
    +}
    +
    +
    +// Return the slot index to read, advancing tail.  Caller checks empty.
    +static int32_t queuePop(HostQueueT *q) {
    +    int32_t idx = q->tail;
    +
    +    q->tail = (q->tail + 1) % q->capacity;
    +    return idx;
    +}
    +
    +
    +// Return the slot index to write, advancing head, or -1 when full.
    +static int32_t queuePush(HostQueueT *q) {
    +    if (queueIsFull(q)) {
    +        return -1;
    +    }
    +
    +    int32_t idx = q->head;
    +
    +    q->head = (q->head + 1) % q->capacity;
    +    return idx;
    +}
    +
    +
    +static uint32_t wallClockMs(void) {
    +    struct timespec ts;
    +
    +    clock_gettime(CLOCK_MONOTONIC, &ts);
    +    return (uint32_t)((uint64_t)ts.tv_sec * MS_PER_SEC + (uint64_t)ts.tv_nsec / NS_PER_MS);
    +}
    +
    +
    +// ============================================================
    +// Harness hooks (dvxPlatHost.h)
    +// ============================================================
    +
    +
    +// ============================================================
    +// Platform contract (dvxPlat.h)
    +// ============================================================
    +
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c b/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
    index e688aa5..2d0e3a3 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
    @@ -52,6 +52,120 @@
     // Transfer block used by platformCopyFile.
     #define COPY_FILE_CHUNK 4096
     
    +// Log file used by dvxLog until platformSetLogPath overrides it.
    +#define DEFAULT_LOG_PATH "dvx.log"
    +
    +// Number of BIOS scan codes covered by the Alt+key lookup table.
    +#define ALT_SCAN_TABLE_SIZE 256
    +
    +
    +// ============================================================
    +// Module state
    +// ============================================================
    +
    +// Current dvxLog destination (see platformSetLogPath).
    +static const char *sLogPath = DEFAULT_LOG_PATH;
    +
    +// Alt+key scan code to ASCII lookup table (indexed by BIOS scan code).
    +// INT 16h returns these scan codes with ascii=0 for Alt+key combos.
    +// Using a 256-byte lookup table instead of a switch or if-chain because
    +// this is called on every keypress and the table fits in a single cache
    +// line cluster. The designated initializer syntax leaves all other
    +// entries as zero, which is the "no mapping" sentinel.
    +static const char sAltScanToAscii[ALT_SCAN_TABLE_SIZE] = {
    +    // Alt+letters
    +    [0x10] = 'q', [0x11] = 'w', [0x12] = 'e', [0x13] = 'r',
    +    [0x14] = 't', [0x15] = 'y', [0x16] = 'u', [0x17] = 'i',
    +    [0x18] = 'o', [0x19] = 'p', [0x1E] = 'a', [0x1F] = 's',
    +    [0x20] = 'd', [0x21] = 'f', [0x22] = 'g', [0x23] = 'h',
    +    [0x24] = 'j', [0x25] = 'k', [0x26] = 'l', [0x2C] = 'z',
    +    [0x2D] = 'x', [0x2E] = 'c', [0x2F] = 'v', [0x30] = 'b',
    +    [0x31] = 'n', [0x32] = 'm',
    +    // Alt+digits
    +    [0x78] = '1', [0x79] = '2', [0x7A] = '3', [0x7B] = '4',
    +    [0x7C] = '5', [0x7D] = '6', [0x7E] = '7', [0x7F] = '8',
    +    [0x80] = '9', [0x81] = '0',
    +};
    +
    +
    +bool dvxHasExt(const char *name, const char *ext) {
    +    if (!name || !ext) {
    +        return false;
    +    }
    +
    +    size_t nameLen = strlen(name);
    +    size_t extLen  = strlen(ext);
    +
    +    if (nameLen < extLen) {
    +        return false;
    +    }
    +
    +    return strcasecmp(name + nameLen - extLen, ext) == 0;
    +}
    +
    +
    +// Append one formatted line to the log file.  Opens/closes the file
    +// per-write so it is never held open across a crash or a task switch.
    +void dvxLog(const char *fmt, ...) {
    +    FILE *f = fopen(sLogPath, "a");
    +
    +    if (!f) {
    +        return;
    +    }
    +
    +    va_list ap;
    +    va_start(ap, fmt);
    +    vfprintf(f, fmt, ap);
    +    va_end(ap);
    +    fprintf(f, "\n");
    +    fclose(f);
    +}
    +
    +
    +char **dvxReadDir(const char *dirPath) {
    +    if (!dirPath) {
    +        return NULL;
    +    }
    +
    +    DIR *dir = opendir(dirPath);
    +
    +    if (!dir) {
    +        return NULL;
    +    }
    +
    +    char          **entries = NULL;
    +    struct dirent  *ent;
    +
    +    while ((ent = readdir(dir)) != NULL) {
    +        // Skip "." and ".."
    +        if (ent->d_name[0] == '.' &&
    +            (ent->d_name[1] == '\0' ||
    +             (ent->d_name[1] == '.' && ent->d_name[2] == '\0'))) {
    +            continue;
    +        }
    +
    +        arrput(entries, strdup(ent->d_name));
    +    }
    +
    +    closedir(dir);
    +    return entries;
    +}
    +
    +
    +void dvxReadDirFree(char **entries) {
    +    if (!entries) {
    +        return;
    +    }
    +
    +    int32_t n = (int32_t)arrlen(entries);
    +
    +    for (int32_t i = 0; i < n; i++) {
    +        free(entries[i]);
    +    }
    +
    +    arrfree(entries);
    +}
    +
     
     const char *dvxSkipWs(const char *s) {
         if (!s) {
    @@ -109,64 +223,12 @@ int32_t dvxTrimRight(char *buf) {
     }
     
     
    -bool dvxHasExt(const char *name, const char *ext) {
    -    if (!name || !ext) {
    -        return false;
    +char platformAltScanToChar(int32_t scancode) {
    +    if (scancode < 0 || scancode >= ALT_SCAN_TABLE_SIZE) {
    +        return 0;
         }
     
    -    size_t nameLen = strlen(name);
    -    size_t extLen  = strlen(ext);
    -
    -    if (nameLen < extLen) {
    -        return false;
    -    }
    -
    -    return strcasecmp(name + nameLen - extLen, ext) == 0;
    -}
    -
    -
    -char **dvxReadDir(const char *dirPath) {
    -    if (!dirPath) {
    -        return NULL;
    -    }
    -
    -    DIR *dir = opendir(dirPath);
    -
    -    if (!dir) {
    -        return NULL;
    -    }
    -
    -    char          **entries = NULL;
    -    struct dirent  *ent;
    -
    -    while ((ent = readdir(dir)) != NULL) {
    -        // Skip "." and ".."
    -        if (ent->d_name[0] == '.' &&
    -            (ent->d_name[1] == '\0' ||
    -             (ent->d_name[1] == '.' && ent->d_name[2] == '\0'))) {
    -            continue;
    -        }
    -
    -        arrput(entries, strdup(ent->d_name));
    -    }
    -
    -    closedir(dir);
    -    return entries;
    -}
    -
    -
    -void dvxReadDirFree(char **entries) {
    -    if (!entries) {
    -        return;
    -    }
    -
    -    int32_t n = (int32_t)arrlen(entries);
    -
    -    for (int32_t i = 0; i < n; i++) {
    -        free(entries[i]);
    -    }
    -
    -    arrfree(entries);
    +    return sAltScanToAscii[scancode];
     }
     
     
    @@ -216,37 +278,42 @@ bool platformCopyFile(const char *srcPath, const char *dstPath) {
     }
     
     
    -const char *platformPathBaseName(const char *path) {
    -    if (!path) {
    -        return "";
    +// Simple glob pattern matching for filenames. Supports:
    +//   *       matches zero or more characters
    +//   ?       matches exactly one character
    +// Case-insensitive. Exported for use by DXE modules.
    +bool platformGlobMatch(const char *pattern, const char *name) {
    +    while (*pattern && *name) {
    +        if (*pattern == '*') {
    +            pattern++;
    +
    +            if (!*pattern) {
    +                return true;
    +            }
    +
    +            while (*name) {
    +                if (platformGlobMatch(pattern, name)) {
    +                    return true;
    +                }
    +
    +                name++;
    +            }
    +
    +            return false;
    +        } else if (*pattern == '?' || tolower((unsigned char)*pattern) == tolower((unsigned char)*name)) {
    +            pattern++;
    +            name++;
    +        } else {
    +            return false;
    +        }
         }
     
    -    const char *sep = platformPathDirEnd(path);
    -    return sep ? sep + 1 : path;
    -}
    -
    -
    -// Return the last path separator in `path`, or NULL if none.
    -//
    -// Platform-specific, because what counts as a separator varies:
    -//   - DJGPP/DOS and Win32 accept both '/' and '\\' -- we have to check
    -//     both and return whichever appears last.
    -//   - Unix-like systems use only '/'.  '\\' is a legal filename
    -//     character there, so treating it as a separator would incorrectly
    -//     split paths containing literal backslashes.
    -char *platformPathDirEnd(const char *path) {
    -#if defined(__DJGPP__) || defined(_WIN32) || defined(_WIN64)
    -    char *fwd  = strrchr(path, '/');
    -    char *back = strrchr(path, '\\');
    -
    -    if (back > fwd) {
    -        return back;
    +    // Skip trailing *
    +    while (*pattern == '*') {
    +        pattern++;
         }
     
    -    return fwd;
    -#else
    -    return strrchr(path, '/');
    -#endif
    +    return *pattern == '\0' && *name == '\0';
     }
     
     
    @@ -278,28 +345,51 @@ int32_t platformMkdirRecursive(const char *path) {
             }
         }
     
    -    // Create the final directory
    -    if (mkdir(buf, MKDIR_MODE) != 0 && errno != EEXIST) {
    -        return -1;
    +    // Create the final directory.  EEXIST is only success when what exists
    +    // is actually a directory.
    +    if (mkdir(buf, MKDIR_MODE) != 0) {
    +        struct stat st;
    +
    +        if (errno != EEXIST || stat(buf, &st) != 0 || !S_ISDIR(st.st_mode)) {
    +            return -1;
    +        }
         }
     
         return 0;
     }
     
     
    -// Remove carriage returns in-place.  Used when reading DOS-format
    -// text files on hosts that don't auto-strip them.
    -int32_t platformStripLineEndings(char *buf, int32_t len) {
    -    int32_t dst = 0;
    -
    -    for (int32_t src = 0; src < len; src++) {
    -        if (buf[src] != '\r') {
    -            buf[dst++] = buf[src];
    -        }
    +const char *platformPathBaseName(const char *path) {
    +    if (!path) {
    +        return "";
         }
     
    -    buf[dst] = '\0';
    -    return dst;
    +    const char *sep = platformPathDirEnd(path);
    +    return sep ? sep + 1 : path;
    +}
    +
    +
    +// Return the last path separator in `path`, or NULL if none.
    +//
    +// Platform-specific, because what counts as a separator varies:
    +//   - DJGPP/DOS and Win32 accept both '/' and '\\' -- we have to check
    +//     both and return whichever appears last.
    +//   - Unix-like systems use only '/'.  '\\' is a legal filename
    +//     character there, so treating it as a separator would incorrectly
    +//     split paths containing literal backslashes.
    +char *platformPathDirEnd(const char *path) {
    +#if defined(__DJGPP__) || defined(_WIN32) || defined(_WIN64)
    +    char *fwd  = strrchr(path, '/');
    +    char *back = strrchr(path, '\\');
    +
    +    if (back > fwd) {
    +        return back;
    +    }
    +
    +    return fwd;
    +#else
    +    return strrchr(path, '/');
    +#endif
     }
     
     
    @@ -354,3 +444,25 @@ char *platformReadFile(const char *path, int32_t *outLen) {
     
         return buf;
     }
    +
    +
    +void platformSetLogPath(const char *path) {
    +    sLogPath = path ? path : DEFAULT_LOG_PATH;
    +}
    +
    +
    +// Remove carriage returns in-place.  Used when reading DOS-format
    +// text files on hosts that don't auto-strip them.
    +int32_t platformStripLineEndings(char *buf, int32_t len) {
    +    int32_t dst = 0;
    +
    +    for (int32_t src = 0; src < len; src++) {
    +        if (buf[src] != '\r') {
    +            buf[dst++] = buf[src];
    +        }
    +    }
    +
    +    buf[dst] = '\0';
    +    return dst;
    +}
    +
    diff --git a/src/libs/kpunch/libdvx/widgetCore.c b/src/libs/kpunch/libdvx/widgetCore.c
    index a27dbb5..2451f8b 100644
    --- a/src/libs/kpunch/libdvx/widgetCore.c
    +++ b/src/libs/kpunch/libdvx/widgetCore.c
    @@ -44,10 +44,10 @@
     #include "dvxDraw.h"
     #include "dvxPlat.h"
     #include "stb_ds_wrap.h"
    +#include "../../../widgets/kpunch/timer/timer.h"
     
     #include 
     #include 
    -#include 
     
     // ============================================================
     // Global state for drag and popup tracking
    @@ -68,7 +68,7 @@
     // and wgtDestroy() handle this cleanup.
     
     bool      sCursorBlinkOn  = true;   // text cursor blink phase (toggled by wgtUpdateCursorBlink)
    -clock_t   sDblClickTicks  = 0;      // set from ctx->dblClickTicks during first paint
    +PlatformTicksT sDblClickTicks = 0;  // set from ctx->dblClickTicks during first paint
     bool      sDebugLayout    = false;
     WidgetT  *sFocusedWidget  = NULL;   // currently focused widget (O(1) access, avoids tree walk)
     WidgetT  *sOpenPopup      = NULL;   // dropdown/combobox with open popup list
    @@ -92,7 +92,7 @@ static int32_t  sClipboardCap = 0;
     #define DBLCLICK_TOLERANCE 4
     
     // Multi-click state (used by widgetEvent.c for universal dbl-click detection)
    -static clock_t sLastClickTime  = 0;
    +static PlatformTicksT sLastClickTime = 0;
     static int32_t sLastClickX     = -1;
     static int32_t sLastClickY     = -1;
     static int32_t sClickCount     = 0;
    @@ -194,7 +194,7 @@ static WidgetT *findNextFocusableImpl(WidgetT *w, WidgetT *after, bool *pastAfte
     
     
     int32_t multiClickDetect(int32_t vx, int32_t vy) {
    -    clock_t now = clock();
    +    PlatformTicksT now = platformClock();
     
         // Guard against multiple calls in the same frame (e.g. widget onMouse
         // calls it, then widgetEvent.c calls it again). If coords and time
    @@ -218,6 +218,40 @@ int32_t multiClickDetect(int32_t vx, int32_t vy) {
     }
     
     
    +void wgtTestReset(void) {
    +    sFocusedWidget  = NULL;
    +    sOpenPopup      = NULL;
    +    sDragWidget     = NULL;
    +    sCursorBlinkOn  = true;
    +    sDebugLayout    = false;
    +    sDblClickTicks  = 0;
    +    sLastClickTime  = 0;
    +    sLastClickX     = -1;
    +    sLastClickY     = -1;
    +    sClickCount     = 0;
    +
    +    arrfree(sPollWidgets);
    +    sPollWidgets = NULL;
    +    arrfree(sWidgetDestroyFns);
    +    sWidgetDestroyFns = NULL;
    +
    +    free(sClipboard);
    +    sClipboard    = NULL;
    +    sClipboardLen = 0;
    +    sClipboardCap = 0;
    +
    +    widgetEventTestReset();
    +    widgetOpsTestReset();
    +    dvxAppTestReset();
    +
    +    const TimerApiT *timerApi = dvxTimerApi();
    +
    +    if (timerApi && timerApi->testReset) {
    +        timerApi->testReset();
    +    }
    +}
    +
    +
     // Appends a child to the end of the parent's child list. O(1)
     // thanks to the lastChild tail pointer. The child list is singly-
     // linked (nextSibling), which saves 4 bytes per widget vs doubly-
    diff --git a/src/libs/kpunch/libdvx/widgetEvent.c b/src/libs/kpunch/libdvx/widgetEvent.c
    index 9a56916..add8d94 100644
    --- a/src/libs/kpunch/libdvx/widgetEvent.c
    +++ b/src/libs/kpunch/libdvx/widgetEvent.c
    @@ -110,6 +110,14 @@ static void dispatchButtonEdges(WidgetT *hit, uint32_t snapGen, int32_t buttons,
     }
     
     
    +void widgetEventTestReset(void) {
    +    sClosedPopup      = NULL;
    +    sPrevMouseButtons = 0;
    +    sPrevMouseX       = -1;
    +    sPrevMouseY       = -1;
    +}
    +
    +
     // Manages automatic scrollbar addition/removal for widget-based windows.
     // Called on every invalidation to ensure scrollbars match the current
     // widget tree's minimum size requirements.
    @@ -248,10 +256,17 @@ void widgetOnBlur(WindowT *win) {
     // widgetOnFocus -- window gained focus
     //
     // Mark the window's content dirty so the compositor knows to
    -// refresh its minimized icon thumbnail if needed.
    +// refresh its minimized icon thumbnail if needed, and request a paint
    +// when no widget holds focus: widgetOnPaint is what hands focus back
    +// (win->lastFocusWidget or the first focusable widget), and nothing
    +// else is guaranteed to dirty the content after an Alt+Tab.
     
     void widgetOnFocus(WindowT *win) {
         win->iconNeedsRefresh = true;
    +
    +    if (win->widgetRoot && !sFocusedWidget) {
    +        wgtInvalidatePaint(win->widgetRoot);
    +    }
     }
     
     
    @@ -394,9 +409,23 @@ static void widgetOnMouseInner(WindowT *win, WidgetT *root, int32_t x, int32_t y
     
         // Handle drag release
         if (sDragWidget && !(buttons & MOUSE_LEFT)) {
    -        wclsOnDragEnd(sDragWidget, root, x, y);
    +        WidgetT *drag = sDragWidget;
    +        uint32_t gen  = sWidgetGen;
    +
    +        // The drag widget captured the press (its onMouseDown fired on the
    +        // press path), so it owns the matching left onMouseUp -- delivered
    +        // before the drag-end handler so a pressable widget sees
    +        // onMouseDown, onMouseUp, onClick in that order.  The callback may
    +        // destroy widgets (widgetClearReferences nulls sDragWidget then).
    +        if (drag->enabled && drag->onMouseUp) {
    +            drag->onMouseUp(drag, 1, x - drag->x - drag->contentOffX, y - drag->y - drag->contentOffY);
    +        }
    +
    +        if (sWidgetGen == gen && sDragWidget) {
    +            wclsOnDragEnd(drag, root, x, y);
    +            wgtInvalidatePaint(sDragWidget);
    +        }
     
    -        wgtInvalidatePaint(sDragWidget);
             sDragWidget = NULL;
     
             // Record the release edge.  Leaving a stale LEFT bit here would
    @@ -484,9 +513,9 @@ static void widgetOnMouseInner(WindowT *win, WidgetT *root, int32_t x, int32_t y
     
         // Left button is up.  If it was just released, deliver the left-button
         // MouseUp to the widget under the cursor (the remainder of this handler
    -    // is press-time logic and is skipped on release).  This is the only path
    -    // that reaches a left onMouseUp, which the early return otherwise left
    -    // unreachable.
    +    // is press-time logic and is skipped on release).  A release that ends
    +    // a drag capture is delivered to the drag widget by the drag-release
    +    // path above instead and never reaches here.
         if (!(buttons & MOUSE_LEFT)) {
             // Deliver the left-button MouseUp (release edge) and any right/middle
             // press/release edges to the widget under the cursor, then return --
    diff --git a/src/libs/kpunch/libdvx/widgetLayout.c b/src/libs/kpunch/libdvx/widgetLayout.c
    index 423112f..c772b7a 100644
    --- a/src/libs/kpunch/libdvx/widgetLayout.c
    +++ b/src/libs/kpunch/libdvx/widgetLayout.c
    @@ -52,6 +52,72 @@
     #include "dvxWgtP.h"
     
     
    +// Public entry point: runs both passes on the entire widget tree.
    +// The root widget is positioned at (0,0) and given the full available
    +// area, then the arrange pass distributes space to its children.
    +//
    +// The window paths (widgetManageScrollbars, widgetOnPaint) do not use
    +// this: they measure once and arrange at the scrolled root geometry
    +// themselves.  This entry point serves callers that need a complete
    +// layout on demand (e.g. dvxFitWindow).
    +
    +void wgtLayout(WidgetT *root, int32_t availW, int32_t availH, const BitmapFontT *font) {
    +    if (!root) {
    +        return;
    +    }
    +
    +    // Measure pass
    +    widgetCalcMinSizeTree(root, font);
    +
    +    // Layout pass
    +    root->x = 0;
    +    root->y = 0;
    +    root->w = availW;
    +    root->h = availH;
    +
    +    widgetLayoutChildren(root, font);
    +}
    +
    +
    +// Decodes a tagged size value into an actual pixel count.
    +//
    +// The tagged integer format uses the high 2 bits as a type tag:
    +//   00 = pixels (value is used directly)
    +//   01 = characters (value * charWidth, for text-relative sizing)
    +//   10 = percent (value% of parentSize, for responsive layouts)
    +//
    +// This encoding allows a single int32_t field to represent any of
    +// three unit types without needing a separate struct or enum.
    +// The tradeoff is that pixel values are limited to 30 bits (~1 billion),
    +// which is far more than any supported display resolution.
    +//
    +// A value of 0 means "auto" (use intrinsic/calculated size)  -- the
    +// caller checks for 0 before calling this function.
    +
    +int32_t wgtResolveSize(int32_t taggedSize, int32_t parentSize, int32_t charWidth) {
    +    if (taggedSize == 0) {
    +        return 0;
    +    }
    +
    +    uint32_t sizeType = (uint32_t)taggedSize & WGT_SIZE_TYPE_MASK;
    +    int32_t  value    = taggedSize & WGT_SIZE_VAL_MASK;
    +
    +    switch (sizeType) {
    +        case WGT_SIZE_PIXELS:
    +            return value;
    +
    +        case WGT_SIZE_CHARS:
    +            return value * charWidth;
    +
    +        case WGT_SIZE_PERCENT:
    +            return (parentSize * value) / 100;
    +
    +        default:
    +            return value;
    +    }
    +}
    +
    +
     // Resolves the layout metrics of a box container: padding and gap come
     // from the widget's tagged sizes (falling back to DEFAULT_PADDING /
     // DEFAULT_SPACING when unset), then a class-supplied getLayoutMetrics
    @@ -388,68 +454,3 @@ void widgetLayoutChildren(WidgetT *w, const BitmapFontT *font) {
         }
     }
     
    -
    -// Public entry point: runs both passes on the entire widget tree.
    -// The root widget is positioned at (0,0) and given the full available
    -// area, then the arrange pass distributes space to its children.
    -//
    -// The window paths (widgetManageScrollbars, widgetOnPaint) do not use
    -// this: they measure once and arrange at the scrolled root geometry
    -// themselves.  This entry point serves callers that need a complete
    -// layout on demand (e.g. dvxFitWindow).
    -
    -void wgtLayout(WidgetT *root, int32_t availW, int32_t availH, const BitmapFontT *font) {
    -    if (!root) {
    -        return;
    -    }
    -
    -    // Measure pass
    -    widgetCalcMinSizeTree(root, font);
    -
    -    // Layout pass
    -    root->x = 0;
    -    root->y = 0;
    -    root->w = availW;
    -    root->h = availH;
    -
    -    widgetLayoutChildren(root, font);
    -}
    -
    -
    -// Decodes a tagged size value into an actual pixel count.
    -//
    -// The tagged integer format uses the high 2 bits as a type tag:
    -//   00 = pixels (value is used directly)
    -//   01 = characters (value * charWidth, for text-relative sizing)
    -//   10 = percent (value% of parentSize, for responsive layouts)
    -//
    -// This encoding allows a single int32_t field to represent any of
    -// three unit types without needing a separate struct or enum.
    -// The tradeoff is that pixel values are limited to 30 bits (~1 billion),
    -// which is far more than any supported display resolution.
    -//
    -// A value of 0 means "auto" (use intrinsic/calculated size)  -- the
    -// caller checks for 0 before calling this function.
    -
    -int32_t wgtResolveSize(int32_t taggedSize, int32_t parentSize, int32_t charWidth) {
    -    if (taggedSize == 0) {
    -        return 0;
    -    }
    -
    -    uint32_t sizeType = (uint32_t)taggedSize & WGT_SIZE_TYPE_MASK;
    -    int32_t  value    = taggedSize & WGT_SIZE_VAL_MASK;
    -
    -    switch (sizeType) {
    -        case WGT_SIZE_PIXELS:
    -            return value;
    -
    -        case WGT_SIZE_CHARS:
    -            return value * charWidth;
    -
    -        case WGT_SIZE_PERCENT:
    -            return (parentSize * value) / 100;
    -
    -        default:
    -            return value;
    -    }
    -}
    diff --git a/src/libs/kpunch/libdvx/widgetOps.c b/src/libs/kpunch/libdvx/widgetOps.c
    index a72b4ee..2ca5d65 100644
    --- a/src/libs/kpunch/libdvx/widgetOps.c
    +++ b/src/libs/kpunch/libdvx/widgetOps.c
    @@ -286,7 +286,7 @@ void wgtInvalidate(WidgetT *w) {
             widgetManageScrollbars(w->window, ctx);
         }
     
    -    // Full repaint — layout changed, all widgets need redrawing
    +    // Full repaint -- layout changed, all widgets need redrawing
         w->window->paintNeeded = PAINT_FULL;
         dvxInvalidateWindow(ctx, w->window);
     }
    @@ -316,7 +316,7 @@ void wgtInvalidatePaint(WidgetT *w) {
             }
         }
     
    -    // Defer the actual paint — it will happen once in the main loop
    +    // Defer the actual paint -- it will happen once in the main loop
         // before compositing, batching multiple invalidations into one
         // tree walk instead of one per call. Don't downgrade FULL to PARTIAL.
         if (w->window->paintNeeded < PAINT_PARTIAL) {
    @@ -458,6 +458,17 @@ static bool widgetDropFocusWithin(WidgetT *w) {
     }
     
     
    +bool widgetFireChange(WidgetT *w) {
    +    uint32_t gen = sWidgetGen;
    +
    +    if (w->onChange) {
    +        w->onChange(w);
    +    }
    +
    +    return sWidgetGen == gen;
    +}
    +
    +
     // Walks the ancestor chain and notifies the nearest ancestor that implements
     // onChildChanged of a structural change to w (destroy, visibility toggle).
     // Stops at the first ancestor that handles it.
    @@ -475,6 +486,11 @@ static void widgetNotifyChildChanged(WidgetT *w) {
     }
     
     
    +void widgetOpsTestReset(void) {
    +    sFullRepaint = false;
    +}
    +
    +
     // Recursive paint walker. For each visible widget:
     //   1. Call the widget's paint function (if any) via vtable.
     //   2. If the widget has WCLASS_PAINTS_CHILDREN, stop recursion  --
    @@ -541,7 +557,7 @@ void widgetPaintOne(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const BitmapFo
             wclsPaint(w, d, ops, font, colors);
         }
     
    -    // Always recurse into children — a clean parent may have dirty children
    +    // Always recurse into children -- a clean parent may have dirty children
         for (WidgetT *c = w->firstChild; c; c = c->nextSibling) {
             widgetPaintOne(c, d, ops, font, colors);
         }
    diff --git a/src/libs/kpunch/libtasks/Makefile b/src/libs/kpunch/libtasks/Makefile
    index 525c8f9..c336b6a 100644
    --- a/src/libs/kpunch/libtasks/Makefile
    +++ b/src/libs/kpunch/libtasks/Makefile
    @@ -37,10 +37,38 @@ OBJS   = $(patsubst %.c,$(OBJDIR)/%.o,$(SRCS))
     TARGETDIR = $(LIBSDIR)/kpunch/libtasks
     TARGET    = $(TARGETDIR)/libtasks.lib
     
    -.PHONY: all clean
    +# Native host build (test harness).  SAN=1 adds ASan/UBSan.
    +HOSTCC        = gcc
    +HOSTOBJDIR    = ../../../../obj/host
    +HOSTTARGET    = $(HOSTOBJDIR)/libtasks.a
    +HOSTCFLAGS    = -O1 -g -fPIC -Wall -Wextra -Werror -Wno-type-limits -Wno-sign-compare -Wno-format-truncation -D_GNU_SOURCE -I../libdvx -I../libdvx/thirdparty
    +ifeq ($(SAN),1)
    +HOSTCFLAGS   += -fsanitize=address,undefined -fno-omit-frame-pointer
    +endif
    +# COV=1 (make coverage): gcov instrumentation under obj/hostcov.
    +ifeq ($(COV),1)
    +HOSTOBJDIR    = ../../../../obj/hostcov
    +HOSTCFLAGS   += --coverage
    +endif
    +HOST_OBJS     = $(patsubst %.c,$(HOSTOBJDIR)/libtasks/%.o,$(SRCS))
    +
    +.PHONY: all clean host host-clean
     
     all: $(TARGET)
     
    +host: $(HOSTTARGET)
    +
    +$(HOSTTARGET): $(HOST_OBJS)
    +	rm -f $@
    +	ar rcs $@ $(HOST_OBJS)
    +
    +$(HOSTOBJDIR)/libtasks/%.o: %.c taskSwch.h ../libdvx/thirdparty/stb_ds.h
    +	@mkdir -p $(dir $@)
    +	$(HOSTCC) $(HOSTCFLAGS) -c -o $@ $<
    +
    +host-clean:
    +	rm -rf $(HOSTOBJDIR)/libtasks $(HOSTTARGET)
    +
     $(TARGET): $(OBJS) | $(TARGETDIR)
     	$(DXE3GEN) -o $(TARGETDIR)/libtasks.dxe -U $(OBJS)
     	mv $(TARGETDIR)/libtasks.dxe $@
    diff --git a/src/libs/kpunch/libtasks/taskswitch.c b/src/libs/kpunch/libtasks/taskswitch.c
    index 5adaa9a..381d0aa 100644
    --- a/src/libs/kpunch/libtasks/taskswitch.c
    +++ b/src/libs/kpunch/libtasks/taskswitch.c
    @@ -49,6 +49,14 @@
     #include 
     #include 
     
    +// Under AddressSanitizer every stack switch is announced so the runtime
    +// tracks the task stacks (and longjmp back to main) instead of guessing.
    +// The guard is a compiler feature test, not a platform test; DJGPP never
    +// defines it.
    +#if defined(__SANITIZE_ADDRESS__)
    +#include 
    +#endif
    +
     // ABI-required stack alignment at function entry (bytes)
     #define STACK_ALIGN 16
     
    @@ -105,6 +113,9 @@ typedef struct {
         TaskEntryT    entry;
         void         *arg;
         int32_t       ctxValue;  // per-task value saved/restored via tsSetContextHooks
    +    void         *asanFake;  // sanitizer fake-stack handle (unused outside ASan)
    +    const void   *asanBottom;
    +    size_t        asanSize;
         bool          isMain;
         bool          allocated; // true if slot is in use, false if free for reuse
     } TaskBlockT;
    @@ -136,14 +147,18 @@ static TsCtxRestoreFnT sCtxRestoreFn = NULL;
     // switcher cannot re-enter tsExit until control resumes on another task and
     // clears this first.
     static uint8_t        *sStackPendingFree = NULL;
    +static uint32_t        sAsanFrom         = 0;   // task we last switched away from
     
     // ============================================================================
     // Forward declarations (alphabetical)
     // ============================================================================
     
    +static void     asanAfterSwitch(void);
    +static void     asanBeforeSwitch(uint32_t prev, uint32_t next, bool exiting);
     static void     contextSwitch(TaskContextT *save, TaskContextT *restore);
     static int32_t  findFreeSlot(void);
     static void     freePendingStack(void);
    +static bool     isRunnable(uint32_t idx);
     static int32_t  scanReady(void);
     static uint32_t scheduleNext(void);
     static void     switchContextValue(uint32_t prev, uint32_t next);
    @@ -169,6 +184,28 @@ void            tsYield(void);
     // Functions (alphabetical)
     // ============================================================================
     
    +// Runs first on the destination stack: hands the sanitizer this task's
    +// fake stack and records the bounds of the stack we came from.
    +static void asanAfterSwitch(void) {
    +#if defined(__SANITIZE_ADDRESS__)
    +    __sanitizer_finish_switch_fiber(tasks[currentIdx].asanFake, &tasks[sAsanFrom].asanBottom, &tasks[sAsanFrom].asanSize);
    +#endif
    +}
    +
    +
    +// Runs last on the departing stack.  An exiting task passes no save slot
    +// so the sanitizer releases its fake stack.
    +static void asanBeforeSwitch(uint32_t prev, uint32_t next, bool exiting) {
    +    sAsanFrom = prev;
    +#if defined(__SANITIZE_ADDRESS__)
    +    __sanitizer_start_switch_fiber(exiting ? NULL : &tasks[prev].asanFake, tasks[next].asanBottom, tasks[next].asanSize);
    +#else
    +    (void)next;
    +    (void)exiting;
    +#endif
    +}
    +
    +
     // Switch execution from the current task to another by saving and restoring
     // callee-saved registers and the stack pointer.  The return address is
     // captured as a local label so that when another task switches back to us,
    @@ -285,13 +322,23 @@ static void freePendingStack(void) {
     // Scan ready tasks (round-robin starting after currentIdx) for one that
     // still has credits.  On a hit, consume one credit and return its index.
     // Returns -1 when no ready task has credits left.
    +// Ready, or the task currently on the CPU (which is Running, not Ready).
    +static bool isRunnable(uint32_t idx) {
    +    return tasks[idx].state == TaskStateReady || (idx == currentIdx && tasks[idx].state == TaskStateRunning);
    +}
    +
    +
     static int32_t scanReady(void) {
         uint32_t count = (uint32_t)arrlen(tasks);
     
         for (uint32_t i = 1; i <= count; i++) {
             uint32_t idx = (currentIdx + i) % count;
     
    -        if (tasks[idx].allocated && tasks[idx].state == TaskStateReady && tasks[idx].credits > 0) {
    +        // The current task is Running rather than Ready; it may keep the
    +        // CPU while it still has credits, otherwise the remainder of its
    +        // budget is thrown away at the next refill and the documented
    +        // (priority + 1) share never materialises.
    +        if (tasks[idx].allocated && isRunnable(idx) && tasks[idx].credits > 0) {
                 tasks[idx].credits--;
                 return (int32_t)idx;
             }
    @@ -334,7 +381,7 @@ static uint32_t scheduleNext(void) {
         bool anyReady = false;
     
         for (uint32_t i = 0; i < count; i++) {
    -        if (tasks[i].allocated && tasks[i].state == TaskStateReady) {
    +        if (tasks[i].allocated && isRunnable(i)) {
                 tasks[i].credits = tasks[i].priority + 1;
                 anyReady         = true;
             }
    @@ -378,6 +425,8 @@ static void switchContextValue(uint32_t prev, uint32_t next) {
     // The trampoline ensures clean task termination even if the app forgets
     // to call tsExit() explicitly.
     static void taskTrampoline(void) {
    +    asanAfterSwitch();
    +
         TaskBlockT *task = &tasks[currentIdx];
         task->entry(task->arg);
         tsExit();
    @@ -435,15 +484,17 @@ int32_t tsCreate(const char *name, TaskEntryT entry, void *arg, uint32_t stackSi
             task->name[TS_NAME_MAX - 1] = '\0';
         }
     
    -    task->stackSize = stackSize;
    -    task->state     = TaskStateReady;
    -    task->priority  = priority;
    -    task->credits   = priority + 1;
    -    task->entry     = entry;
    -    task->arg       = arg;
    -    task->ctxValue  = 0;
    -    task->isMain    = false;
    -    task->allocated = true;
    +    task->stackSize  = stackSize;
    +    task->asanBottom = task->stack;
    +    task->asanSize   = stackSize;
    +    task->state      = TaskStateReady;
    +    task->priority   = priority;
    +    task->credits    = priority + 1;
    +    task->entry      = entry;
    +    task->arg        = arg;
    +    task->ctxValue   = 0;
    +    task->isMain     = false;
    +    task->allocated  = true;
     
         // Set up initial stack (grows downward, 16-byte aligned).
         // The ABI requires 16-byte stack alignment at function entry. We align
    @@ -511,6 +562,7 @@ void tsExit(void) {
         tasks[next].state = TaskStateRunning;
     
         switchContextValue(prev, next);
    +    asanBeforeSwitch(prev, next, true);
         contextSwitch(&tasks[prev].context, &tasks[next].context);
         // Terminated task never resumes here
     }
    @@ -649,7 +701,9 @@ int32_t tsPause(uint32_t taskId) {
                 tasks[next].state = TaskStateRunning;
     
                 switchContextValue(prev, next);
    +            asanBeforeSwitch(prev, next, false);
                 contextSwitch(&tasks[prev].context, &tasks[next].context);
    +            asanAfterSwitch();
             }
         }
     
    @@ -676,6 +730,15 @@ void tsRecoverToMain(void) {
         currentIdx     = 0;
         tasks[0].state = TaskStateRunning;
     
    +#if defined(__SANITIZE_ADDRESS__)
    +    // The longjmp landed on main's stack without a switch; re-announce it
    +    // (bounds were captured the first time main switched away).
    +    if (tasks[0].asanBottom) {
    +        __sanitizer_start_switch_fiber(NULL, tasks[0].asanBottom, tasks[0].asanSize);
    +        __sanitizer_finish_switch_fiber(NULL, NULL, NULL);
    +    }
    +#endif
    +
         // Crash recovery longjmps here without a normal switch, so restore
         // the main task's context value explicitly -- otherwise the crashed
         // task's value (e.g. its app id) leaks into the shell.
    @@ -781,5 +844,7 @@ void tsYield(void) {
         tasks[next].state = TaskStateRunning;
     
         switchContextValue(prev, next);
    +    asanBeforeSwitch(prev, next, false);
         contextSwitch(&tasks[prev].context, &tasks[next].context);
    +    asanAfterSwitch();
     }
    diff --git a/src/libs/kpunch/listhelp/listHelp.c b/src/libs/kpunch/listhelp/listHelp.c
    index 3e7c05e..c966b5f 100644
    --- a/src/libs/kpunch/listhelp/listHelp.c
    +++ b/src/libs/kpunch/listhelp/listHelp.c
    @@ -259,7 +259,7 @@ bool widgetPopupScrollbarClick(int32_t x, int32_t y, int32_t popX, int32_t popY,
                 (*scrollPos)++;
             }
         } else {
    -        // Trough — page up/down based on which side of thumb
    +        // Trough -- page up/down based on which side of thumb
             int32_t trackLen  = popH - POPUP_SCROLLBAR_W * 2;
             int32_t thumbSize = (int32_t)(((int64_t)visibleItems * trackLen) / itemCount);
     
    diff --git a/src/libs/kpunch/serial/rs232/rs232.c b/src/libs/kpunch/serial/rs232/rs232.c
    index 94ef073..9e8dc20 100644
    --- a/src/libs/kpunch/serial/rs232/rs232.c
    +++ b/src/libs/kpunch/serial/rs232/rs232.c
    @@ -395,7 +395,7 @@ int            rs232WriteBuf(int com, const char *data, int len);
     
     
     // ========================================================================
    -// Interrupt service routine
    +// Baud rate helpers and interrupt service routine
     // ========================================================================
     
     static int32_t bpsToDivisor(int32_t bps) {
    @@ -410,18 +410,6 @@ static int32_t bpsToDivisor(int32_t bps) {
     }
     
     
    -static int32_t divisorToBps(uint16_t divisor) {
    -    uint32_t i;
    -
    -    for (i = 0; i < BPS_MAP_COUNT; i++) {
    -        if (sBpsMap[i].divisor == divisor) {
    -            return sBpsMap[i].bps;
    -        }
    -    }
    -    return RS232_ERR_INVALID_BPS;
    -}
    -
    -
     // Single shared ISR for all COM ports. ISR sharing is necessary because
     // COM1/COM3 typically share IRQ4 and COM2/COM4 share IRQ3. Having one ISR
     // that polls all ports avoids the complexity of per-IRQ handlers.
    @@ -551,6 +539,18 @@ static void comGeneralIsr(void) {
     }
     
     
    +static int32_t divisorToBps(uint16_t divisor) {
    +    uint32_t i;
    +
    +    for (i = 0; i < BPS_MAP_COUNT; i++) {
    +        if (sBpsMap[i].divisor == divisor) {
    +            return sBpsMap[i].bps;
    +        }
    +    }
    +    return RS232_ERR_INVALID_BPS;
    +}
    +
    +
     // ========================================================================
     // DPMI utility functions
     // ========================================================================
    @@ -781,6 +781,11 @@ static int installIrqHandler(int irq) {
     }
     
     
    +static void irqRestore(uint32_t flags) {
    +    asm volatile("pushl %0\n\tpopfl" : : "r"(flags) : "memory", "cc");
    +}
    +
    +
     // Disable interrupts, returning the previous EFLAGS so the caller can
     // restore the interrupt state it was entered with instead of blindly
     // re-enabling with STI.
    @@ -793,11 +798,6 @@ static uint32_t irqSave(void) {
     }
     
     
    -static void irqRestore(uint32_t flags) {
    -    asm volatile("pushl %0\n\tpopfl" : : "r"(flags) : "memory", "cc");
    -}
    -
    -
     static uint8_t picReadIrr(uint16_t port) {
         PIC_WRITE_OCW3(port, PIC_RR);
         return inportb(port);
    @@ -1340,10 +1340,18 @@ int rs232SetData(int com, int dataBits) {
         }
     
         switch (dataBits) {
    -        case 5: UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~DATA_MASK) | DATA_5); return RS232_SUCCESS;
    -        case 6: UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~DATA_MASK) | DATA_6); return RS232_SUCCESS;
    -        case 7: UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~DATA_MASK) | DATA_7); return RS232_SUCCESS;
    -        case 8: UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~DATA_MASK) | DATA_8); return RS232_SUCCESS;
    +        case 5:
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~DATA_MASK) | DATA_5);
    +            return RS232_SUCCESS;
    +        case 6:
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~DATA_MASK) | DATA_6);
    +            return RS232_SUCCESS;
    +        case 7:
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~DATA_MASK) | DATA_7);
    +            return RS232_SUCCESS;
    +        case 8:
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~DATA_MASK) | DATA_8);
    +            return RS232_SUCCESS;
         }
         return RS232_ERR_INVALID_DATA;
     }
    @@ -1483,11 +1491,26 @@ int rs232SetParity(int com, char parity) {
         }
     
         switch (parity) {
    -        case 'n': case 'N': UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~PARITY_MASK) | PARITY_NONE);  return RS232_SUCCESS;
    -        case 'e': case 'E': UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~PARITY_MASK) | PARITY_EVEN);  return RS232_SUCCESS;
    -        case 'o': case 'O': UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~PARITY_MASK) | PARITY_ODD);   return RS232_SUCCESS;
    -        case 'm': case 'M': UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~PARITY_MASK) | PARITY_MARK);  return RS232_SUCCESS;
    -        case 's': case 'S': UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~PARITY_MASK) | PARITY_SPACE); return RS232_SUCCESS;
    +        case 'n':
    +        case 'N':
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~PARITY_MASK) | PARITY_NONE);
    +            return RS232_SUCCESS;
    +        case 'e':
    +        case 'E':
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~PARITY_MASK) | PARITY_EVEN);
    +            return RS232_SUCCESS;
    +        case 'o':
    +        case 'O':
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~PARITY_MASK) | PARITY_ODD);
    +            return RS232_SUCCESS;
    +        case 'm':
    +        case 'M':
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~PARITY_MASK) | PARITY_MARK);
    +            return RS232_SUCCESS;
    +        case 's':
    +        case 'S':
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~PARITY_MASK) | PARITY_SPACE);
    +            return RS232_SUCCESS;
         }
         return RS232_ERR_INVALID_PARITY;
     }
    @@ -1524,8 +1547,12 @@ int rs232SetStop(int com, int stopBits) {
         }
     
         switch (stopBits) {
    -        case 1: UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~STOP_MASK) | STOP_1); return RS232_SUCCESS;
    -        case 2: UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~STOP_MASK) | STOP_2); return RS232_SUCCESS;
    +        case 1:
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~STOP_MASK) | STOP_1);
    +            return RS232_SUCCESS;
    +        case 2:
    +            UART_WRITE_LCR(port, (UART_READ_LCR(port) & ~STOP_MASK) | STOP_2);
    +            return RS232_SUCCESS;
         }
         return RS232_ERR_INVALID_STOP;
     }
    diff --git a/src/libs/kpunch/serial/seclink/secLink.c b/src/libs/kpunch/serial/seclink/secLink.c
    index c2cace8..e3ca41c 100644
    --- a/src/libs/kpunch/serial/seclink/secLink.c
    +++ b/src/libs/kpunch/serial/seclink/secLink.c
    @@ -124,21 +124,13 @@ static void completeHandshake(SecLinkT *link) {
         uint8_t txKey[SEC_XTEA_KEY_SIZE];
         uint8_t rxKey[SEC_XTEA_KEY_SIZE];
         bool    weAreLower;
    -    int     rc;
     
         // An invalid remote key (out of range, e.g. all zeros) must fail the
         // handshake -- ignoring these return codes would leave masterKey as
         // uninitialized stack memory and enter READY with garbage key material.
    -    rc = secDhComputeSecret(link->dh, link->remoteKey, SEC_DH_KEY_SIZE);
    -    if (rc != SEC_SUCCESS) {
    -        secDhDestroy(link->dh);
    -        link->dh    = 0;
    -        link->state = STATE_ERROR;
    -        return;
    -    }
    -
    -    rc = secDhDeriveKey(link->dh, masterKey, SEC_XTEA_KEY_SIZE);
    -    if (rc != SEC_SUCCESS) {
    +    // The derive call cannot fail once the secret is computed, but it is
    +    // kept under the same check so a future error path stays covered.
    +    if (secDhComputeSecret(link->dh, link->remoteKey, SEC_DH_KEY_SIZE) != SEC_SUCCESS || secDhDeriveKey(link->dh, masterKey, SEC_XTEA_KEY_SIZE) != SEC_SUCCESS) {
             secDhDestroy(link->dh);
             link->dh    = 0;
             link->state = STATE_ERROR;
    @@ -282,12 +274,9 @@ int secLinkHandshake(SecLinkT *link) {
             return SECLINK_ERR_ALLOC;
         }
     
    -    rc = secDhGenerateKeys(link->dh);
    -    if (rc != SEC_SUCCESS) {
    -        secDhDestroy(link->dh);
    -        link->dh = 0;
    -        return SECLINK_ERR_HANDSHAKE;
    -    }
    +    // Cannot fail for a non-NULL context (the only failure is a NULL dh,
    +    // which was checked above).
    +    secDhGenerateKeys(link->dh);
     
         // Export our public key
         len = SEC_DH_KEY_SIZE;
    diff --git a/src/libs/kpunch/serial/security/security.c b/src/libs/kpunch/serial/security/security.c
    index 618328b..fae6f92 100644
    --- a/src/libs/kpunch/serial/security/security.c
    +++ b/src/libs/kpunch/serial/security/security.c
    @@ -56,6 +56,8 @@ typedef char secDhKeySizeMatch[(BN_BYTES == SEC_DH_KEY_SIZE) ? 1 : -1];
     
     #define DH_PRIVATE_BITS     256
     #define DH_PRIVATE_BYTES    (DH_PRIVATE_BITS / 8)
    +#define DH_PRIVATE_WORDS    (DH_PRIVATE_BITS / 32)
    +#define DH_PRIVATE_TOP_BIT  0x80000000u
     
     #define XTEA_ROUNDS         32
     #define XTEA_DELTA          0x9E3779B9
    @@ -580,10 +582,12 @@ int secDhGenerateKeys(SecDhT *dh) {
         bnClear(&dh->privateKey);
         secRngBytes((uint8_t *)dh->privateKey.w, DH_PRIVATE_BYTES);
     
    -    // Ensure private key >= 2
    -    if (bnBitLength(&dh->privateKey) <= 1) {
    -        dh->privateKey.w[0] = 2;
    -    }
    +    // Force the top bit of the 256-bit exponent.  This guarantees the
    +    // private key is >= 2 (a zero or one exponent would make the public
    +    // value trivial) and fixes the exponent bit length at DH_PRIVATE_BITS,
    +    // so the square-and-multiply loop in bnModExp always runs the same
    +    // number of iterations regardless of the random draw.
    +    dh->privateKey.w[DH_PRIVATE_WORDS - 1] |= DH_PRIVATE_TOP_BIT;
     
         // public = g^private mod p
         bnModExp(&dh->publicKey, &sDhGenerator, &dh->privateKey, &sDhPrime, sDhM0Inv, &sDhR2);
    diff --git a/src/libs/kpunch/serial/security/security.h b/src/libs/kpunch/serial/security/security.h
    index 4d9f252..af89ec6 100644
    --- a/src/libs/kpunch/serial/security/security.h
    +++ b/src/libs/kpunch/serial/security/security.h
    @@ -69,14 +69,14 @@ typedef struct SecCipherS SecCipherT;
     
     // RNG  -- seed before generating keys. Hardware entropy is weak (~20 bits);
     // callers should supplement with keyboard timing, mouse jitter, etc.
    -int  secRngGatherEntropy(uint8_t *buf, int len);
     void secRngAddEntropy(const uint8_t *data, int len);
     void secRngBytes(uint8_t *buf, int len);
    +int  secRngGatherEntropy(uint8_t *buf, int len);
     void secRngSeed(const uint8_t *entropy, int len);
     
     // Diffie-Hellman key exchange (1024-bit, RFC 2409 Group 2)
    -SecDhT *secDhCreate(void);
     int     secDhComputeSecret(SecDhT *dh, const uint8_t *remotePub, int len);
    +SecDhT *secDhCreate(void);
     int     secDhDeriveKey(SecDhT *dh, uint8_t *key, int keyLen);
     void    secDhDestroy(SecDhT *dh);
     int     secDhGenerateKeys(SecDhT *dh);
    diff --git a/src/libs/kpunch/sql/Makefile b/src/libs/kpunch/sql/Makefile
    index 7679765..01feacd 100644
    --- a/src/libs/kpunch/sql/Makefile
    +++ b/src/libs/kpunch/sql/Makefile
    @@ -75,10 +75,68 @@ MKKEYWORDHASH  = $(OBJDIR)/mkkeywordhash
     
     OBJS = $(OBJDIR)/dvxSql.o $(SQLITE_OBJS) $(OBJDIR)/sqlite_parse.o $(OBJDIR)/sqlite_opcodes.o
     
    -.PHONY: all clean
    +# ---- Native host build (test harness) ----
    +#
    +# Builds the same SQLite sources plus the wrapper with the host gcc into
    +# a static archive the test executables link with -rdynamic so the
    +# datactrl widget .so resolves dvxSql* at load time.  SAN=1 (default)
    +# matches the dvxtest sanitizer flags; COV=1 instruments for gcov under
    +# obj/hostcov.  The generated sources (phase 1) are shared with the DOS
    +# build.
    +SAN            ?= 1
    +ifeq ($(COV),1)
    +HOSTSUB         = hostcov
    +else
    +HOSTSUB         = host
    +endif
    +HOSTOBJDIR      = ../../../../obj/$(HOSTSUB)/sql
    +HOSTTARGET      = ../../../../obj/$(HOSTSUB)/dvxsql.a
    +HOSTCFLAGS      = -O1 -g -fPIC -Wall -Wextra -Werror -Wno-type-limits -Wno-sign-compare -Wno-format-truncation -D_GNU_SOURCE \
    +                  -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION \
    +                  -I$(SQLITE_SRC) -I$(SQLITE_DIR)/examples -I$(OBJDIR)
    +# Vendored SQLite 3.5 predates modern gcc diagnostics; it is third party
    +# code and is compiled without -Werror and without UBSan (its hash
    +# function relies on signed shift wraparound).
    +HOSTSQLITEFLAGS = -O1 -g -fPIC -w -D_GNU_SOURCE -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION -I$(SQLITE_SRC) -I$(SQLITE_DIR)/examples -I$(OBJDIR)
    +ifeq ($(SAN),1)
    +HOSTCFLAGS     += -fsanitize=address,undefined -fno-omit-frame-pointer
    +HOSTSQLITEFLAGS+= -fsanitize=address -fno-omit-frame-pointer
    +endif
    +ifeq ($(COV),1)
    +HOSTCFLAGS     += --coverage
    +endif
    +
    +HOST_SQLITE_OBJS = $(SQLITE_SRCS:%=$(HOSTOBJDIR)/sqlite_%.o) $(HOSTOBJDIR)/sqlite_parse.o $(HOSTOBJDIR)/sqlite_opcodes.o
    +HOST_OBJS        = $(HOSTOBJDIR)/dvxSql.o $(HOST_SQLITE_OBJS)
    +
    +.PHONY: all clean host host-clean
     
     all: $(TARGET) $(TARGETDIR)/dvxsql.dep
     
    +host: $(HOSTTARGET)
    +
    +$(HOSTTARGET): $(HOST_OBJS)
    +	rm -f $@
    +	ar rcs $@ $(HOST_OBJS)
    +
    +$(HOSTOBJDIR)/dvxSql.o: dvxSql.c dvxSql.h | $(HOSTOBJDIR)
    +	$(HOSTCC) $(HOSTCFLAGS) -c -o $@ $<
    +
    +$(HOSTOBJDIR)/sqlite_%.o: $(SQLITE_SRC)/%.c $(GEN_OPCODES_H) $(GEN_KEYWORDHASH) $(GEN_PARSE_H) | $(HOSTOBJDIR)
    +	$(HOSTCC) $(HOSTSQLITEFLAGS) -c -o $@ $<
    +
    +$(HOSTOBJDIR)/sqlite_parse.o: $(GEN_PARSE_C) $(GEN_OPCODES_H) | $(HOSTOBJDIR)
    +	$(HOSTCC) $(HOSTSQLITEFLAGS) -c -o $@ $<
    +
    +$(HOSTOBJDIR)/sqlite_opcodes.o: $(GEN_OPCODES_C) | $(HOSTOBJDIR)
    +	$(HOSTCC) $(HOSTSQLITEFLAGS) -c -o $@ $<
    +
    +$(HOSTOBJDIR):
    +	mkdir -p $(HOSTOBJDIR)
    +
    +host-clean:
    +	rm -rf $(HOSTOBJDIR) $(HOSTTARGET)
    +
     $(TARGETDIR)/dvxsql.dep: dvxsql.dep | $(TARGETDIR)
     	sed 's/$$/\r/' $< > $@
     
    diff --git a/src/libs/kpunch/texthelp/textHelp.c b/src/libs/kpunch/texthelp/textHelp.c
    index f18b890..0764ca3 100644
    --- a/src/libs/kpunch/texthelp/textHelp.c
    +++ b/src/libs/kpunch/texthelp/textHelp.c
    @@ -30,7 +30,6 @@
     #include "textHelp.h"
     
     #include 
    -#include 
     
     // ============================================================
     // Static state
    @@ -38,7 +37,7 @@
     
     // Cursor blink state
     #define CURSOR_BLINK_MS 250
    -static clock_t sCursorBlinkTime = 0;
    +static PlatformTicksT sCursorBlinkTime = 0;
     
     // Slack added to line cache capacity when growing. Bigger values
     // mean fewer reallocs at the cost of wasted space; 256 empirically
    @@ -62,8 +61,8 @@ static WidgetT *sLastSelectedWidget = NULL;
     // ============================================================
     
     void           clearOtherSelections(WidgetT *except);
    -void           textEditClearWidgetRef(WidgetT *w);
     bool           isWordChar(char c);
    +void           textEditClearWidgetRef(WidgetT *w);
     static void    textEditDeleteSelection(char *buf, int32_t *pLen, int32_t *pCursor, int32_t *pSelStart, int32_t *pSelEnd);
     void           textEditDrawColorizedText(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t len, const uint8_t *syntaxColors, int32_t textOff, uint32_t defaultFg, uint32_t bg, const uint32_t *customColors);
     void           textEditEnsureVisible(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t cursorRow, int32_t cursorCol, int32_t tabW, int32_t visRows, int32_t visCols, int32_t *pScrollRow, int32_t *pScrollCol);
    @@ -78,12 +77,12 @@ void           textEditLineCacheNotifyDelete(TextEditLineCacheT *lc, const char
     void           textEditLineCacheNotifyInsert(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t insertLen);
     static void    textEditLineCacheRebuild(TextEditLineCacheT *lc, const char *buf, int32_t len);
     void           textEditLineCacheSetTabWidth(TextEditLineCacheT *lc, int32_t tabWidth);
    -void           textEditMultiFree(TextEditMultiT *te);
    -bool           textEditMultiInit(TextEditMultiT *te, int32_t maxLen, int32_t tabWidth);
     int32_t        textEditLineCount(TextEditLineCacheT *lc, const char *buf, int32_t len);
     int32_t        textEditLineLen(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t row);
     int32_t        textEditLineStart(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t row);
     int32_t        textEditMaxLineLen(TextEditLineCacheT *lc, const char *buf, int32_t len);
    +void           textEditMultiFree(TextEditMultiT *te);
    +bool           textEditMultiInit(TextEditMultiT *te, int32_t maxLen, int32_t tabWidth);
     void           textEditOffToRowCol(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t *row, int32_t *col);
     int32_t        textEditReplaceAll(TextEditLineCacheT *lc, char *buf, int32_t bufSize, int32_t *pLen, const char *needle, const char *replacement, bool caseSensitive, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor);
     bool           textEditReplaceSelectionMulti(TextEditLineCacheT *lc, char *buf, int32_t bufSize, int32_t *pLen, const char *needle, const char *replacement, bool caseSensitive, bool forward, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor);
    @@ -93,6 +92,7 @@ uint32_t       textEditSyntaxColor(const DisplayT *d, uint8_t idx, uint32_t defa
     int32_t        textEditVisualCol(const char *buf, int32_t lineStart, int32_t off, int32_t tabW);
     int32_t        textEditVisualColToOff(const char *buf, int32_t len, int32_t lineStart, int32_t targetVC, int32_t tabW);
     static void    textHelpInit(void) __attribute__((constructor));
    +void           textHelpTestReset(void);
     void           wgtUpdateCursorBlink(void);
     void           widgetTextEditDragUpdateLine(int32_t vx, int32_t leftEdge, int32_t maxChars, const BitmapFontT *font, int32_t len, int32_t *pCursorPos, int32_t *pScrollOff, int32_t *pSelEnd);
     void           widgetTextEditMouseClick(WidgetT *w, int32_t vx, int32_t vy, int32_t textLeftX, const BitmapFontT *font, const char *buf, int32_t len, int32_t scrollOff, int32_t *pCursorPos, int32_t *pSelStart, int32_t *pSelEnd, bool wordSelect, bool dragSelect);
    @@ -100,11 +100,11 @@ void           widgetTextEditMultiDragUpdateArea(TextEditLineCacheT *lc, const c
     void           widgetTextEditMultiMouseClick(WidgetT *w, TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t vx, int32_t vy, int32_t textX, int32_t textY, const BitmapFontT *font, int32_t scrollRow, int32_t scrollCol, int32_t tabW, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor);
     void           widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLineCacheT *lc, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pScrollRow, int32_t *pScrollCol, int32_t *pSelAnchor, int32_t *pSelCursor, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t visRows, int32_t visCols, const TextEditMultiOptionsT *opts);
     void           widgetTextEditMultiPaintArea(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t textX, int32_t textY, int32_t innerW, int32_t visCols, int32_t visRows, int32_t scrollRow, int32_t scrollCol, int32_t cursorRow, int32_t cursorCol, int32_t selAnchor, int32_t selCursor, int32_t tabW, uint32_t fg, uint32_t bg, bool showCursor, const TextEditPaintHooksT *hooks);
    -void           widgetTextScrollbarDraw(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, bool vertical, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll);
    -int32_t        widgetTextScrollbarDragToScroll(bool vertical, int32_t mouseCoord, int32_t sbStart, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t dragOff);
    -int32_t        widgetTextScrollbarHitTest(bool vertical, int32_t vx, int32_t vy, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll, int32_t *pDragOff);
     void           widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursor, int32_t *pScrollOff, int32_t *pSelStart, int32_t *pSelEnd, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t fieldWidth);
     void           widgetTextEditPaintLine(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, const ColorSchemeT *colors, int32_t textX, int32_t textY, const char *buf, int32_t visLen, int32_t scrollOff, int32_t cursorPos, int32_t selStart, int32_t selEnd, uint32_t fg, uint32_t bg, bool showCursor, int32_t cursorMinX, int32_t cursorMaxX);
    +int32_t        widgetTextScrollbarDragToScroll(bool vertical, int32_t mouseCoord, int32_t sbStart, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t dragOff);
    +void           widgetTextScrollbarDraw(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, bool vertical, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll);
    +int32_t        widgetTextScrollbarHitTest(bool vertical, int32_t vx, int32_t vy, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll, int32_t *pDragOff);
     int32_t wordBoundaryLeft(const char *buf, int32_t pos);
     int32_t wordBoundaryRight(const char *buf, int32_t len, int32_t pos);
     int32_t        wordEnd(const char *buf, int32_t len, int32_t pos);
    @@ -191,12 +191,6 @@ static void textEditDeleteSelection(char *buf, int32_t *pLen, int32_t *pCursor,
             hi = *pLen;
         }
     
    -    if (lo >= hi) {
    -        *pSelStart = -1;
    -        *pSelEnd   = -1;
    -        return;
    -    }
    -
         memmove(buf + lo, buf + hi, *pLen - hi + 1);
         *pLen    -= (hi - lo);
         *pCursor  = lo;
    @@ -302,11 +296,21 @@ bool textEditFindNext(TextEditLineCacheT *lc, const char *buf, int32_t len, cons
             }
     
             if (match) {
    -            *pSelAnchor = pos;
    -            *pSelCursor = pos + needleLen;
    +            // Park the cursor on the far side of the match relative to the
    +            // search direction (start when backward, start when forward via
    +            // the anchor/cursor split below), so repeating the search in the
    +            // same direction advances past this match instead of re-finding
    +            // it.  The selection always covers the match; only which end is
    +            // the live cursor differs.
    +            if (forward) {
    +                *pSelAnchor = pos;
    +                *pSelCursor = pos + needleLen;
    +            } else {
    +                *pSelAnchor = pos + needleLen;
    +                *pSelCursor = pos;
    +            }
     
    -            int32_t cursorOff = forward ? pos : pos + needleLen;
    -            textEditOffToRowCol(lc, buf, len, cursorOff, pCursorRow, pCursorCol);
    +            textEditOffToRowCol(lc, buf, len, pos, pCursorRow, pCursorCol);
                 *pDesiredCol = *pCursorCol;
                 return true;
             }
    @@ -627,46 +631,6 @@ void textEditLineCacheSetTabWidth(TextEditLineCacheT *lc, int32_t tabWidth) {
     }
     
     
    -void textEditMultiFree(TextEditMultiT *te) {
    -    free(te->buf);
    -    free(te->undoBuf);
    -    textEditLineCacheFree(&te->lines);
    -    te->buf     = NULL;
    -    te->undoBuf = NULL;
    -    te->bufSize = 0;
    -    te->len     = 0;
    -}
    -
    -
    -// Allocates the buf/undoBuf pair sized for maxLen characters plus
    -// NUL, and initializes cursor/scroll/selection/undo state.  Returns
    -// false (leaving te->buf == NULL) if allocation fails.
    -bool textEditMultiInit(TextEditMultiT *te, int32_t maxLen, int32_t tabWidth) {
    -    memset(te, 0, sizeof(*te));
    -
    -    int32_t bufSize = maxLen > 0 ? maxLen + 1 : 256;
    -
    -    te->buf     = (char *)malloc(bufSize);
    -    te->undoBuf = (char *)malloc(bufSize);
    -    te->bufSize = bufSize;
    -
    -    if (!te->buf || !te->undoBuf) {
    -        free(te->buf);
    -        free(te->undoBuf);
    -        te->buf     = NULL;
    -        te->undoBuf = NULL;
    -        return false;
    -    }
    -
    -    te->buf[0]     = '\0';
    -    te->undoBuf[0] = '\0';
    -    te->selAnchor  = -1;
    -    te->selCursor  = -1;
    -    textEditLineCacheInit(&te->lines, tabWidth);
    -    return true;
    -}
    -
    -
     int32_t textEditLineCount(TextEditLineCacheT *lc, const char *buf, int32_t len) {
         textEditLineCacheEnsure(lc, buf, len);
         return lc->cachedLines;
    @@ -725,6 +689,46 @@ int32_t textEditMaxLineLen(TextEditLineCacheT *lc, const char *buf, int32_t len)
     }
     
     
    +void textEditMultiFree(TextEditMultiT *te) {
    +    free(te->buf);
    +    free(te->undoBuf);
    +    textEditLineCacheFree(&te->lines);
    +    te->buf     = NULL;
    +    te->undoBuf = NULL;
    +    te->bufSize = 0;
    +    te->len     = 0;
    +}
    +
    +
    +// Allocates the buf/undoBuf pair sized for maxLen characters plus
    +// NUL, and initializes cursor/scroll/selection/undo state.  Returns
    +// false (leaving te->buf == NULL) if allocation fails.
    +bool textEditMultiInit(TextEditMultiT *te, int32_t maxLen, int32_t tabWidth) {
    +    memset(te, 0, sizeof(*te));
    +
    +    int32_t bufSize = maxLen > 0 ? maxLen + 1 : 256;
    +
    +    te->buf     = (char *)malloc(bufSize);
    +    te->undoBuf = (char *)malloc(bufSize);
    +    te->bufSize = bufSize;
    +
    +    if (!te->buf || !te->undoBuf) {
    +        free(te->buf);
    +        free(te->undoBuf);
    +        te->buf     = NULL;
    +        te->undoBuf = NULL;
    +        return false;
    +    }
    +
    +    te->buf[0]     = '\0';
    +    te->undoBuf[0] = '\0';
    +    te->selAnchor  = -1;
    +    te->selCursor  = -1;
    +    textEditLineCacheInit(&te->lines, tabWidth);
    +    return true;
    +}
    +
    +
     void textEditOffToRowCol(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t *row, int32_t *col) {
         textEditLineCacheEnsure(lc, buf, len);
     
    @@ -746,8 +750,11 @@ void textEditOffToRowCol(TextEditLineCacheT *lc, const char *buf, int32_t len, i
             return;
         }
     
    +    // Search rows [0, cachedLines-1] only: lineOffsets[cachedLines] is the
    +    // buffer-end sentinel, and an offset at the very end of a buffer that
    +    // does not end in a newline must map to the LAST line, not one past it.
         int32_t lo = 0;
    -    int32_t hi = lc->cachedLines;
    +    int32_t hi = lc->cachedLines - 1;
     
         while (lo < hi) {
             int32_t mid = (lo + hi + 1) / 2;
    @@ -818,15 +825,11 @@ int32_t textEditReplaceAll(TextEditLineCacheT *lc, char *buf, int32_t bufSize, i
             *pSelAnchor = -1;
             *pSelCursor = -1;
             // Dirty the cache BEFORE converting the cursor: replaceAll changed the
    -        // buffer, so the row/col conversions below must rebuild from it.
    +        // buffer, so the row/col conversions below must rebuild from it.  The
    +        // cursor is re-derived from its clamped byte offset so a row that no
    +        // longer exists (a replaced newline) cannot linger in *pCursorRow.
             textEditLineCacheDirty(lc);
    -
    -        int32_t cursorOff = textEditRowColToOff(lc, buf, *pLen, *pCursorRow, *pCursorCol);
    -
    -        if (cursorOff > *pLen) {
    -            textEditOffToRowCol(lc, buf, *pLen, *pLen, pCursorRow, pCursorCol);
    -        }
    -
    +        textEditOffToRowCol(lc, buf, *pLen, textEditRowColToOff(lc, buf, *pLen, *pCursorRow, *pCursorCol), pCursorRow, pCursorCol);
             *pDesiredCol = *pCursorCol;
         }
     
    @@ -996,9 +999,20 @@ static void textHelpInit(void) {
     }
     
     
    +// Test-harness hook: wgtTestReset drops the blink hook and the destroy
    +// subscriber list this module registered from its constructor, so the
    +// harness calls this after every reset to clear the selection tracker
    +// and register again.
    +void textHelpTestReset(void) {
    +    sLastSelectedWidget = NULL;
    +    sCursorBlinkTime    = 0;
    +    textHelpInit();
    +}
    +
    +
     void wgtUpdateCursorBlink(void) {
    -    clock_t now      = clock();
    -    clock_t interval = (clock_t)CURSOR_BLINK_MS * CLOCKS_PER_SEC / 1000;
    +    PlatformTicksT now      = platformClock();
    +    PlatformTicksT interval = PLATFORM_MS_TO_TICKS(CURSOR_BLINK_MS);
     
         if ((now - sCursorBlinkTime) >= interval) {
             sCursorBlinkTime = now;
    @@ -1234,14 +1248,12 @@ void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLine
         #define SEL_LO()    (*pSA < *pSC ? *pSA : *pSC)
         #define SEL_HI()    (*pSA < *pSC ? *pSC : *pSA)
     
    -    if (HAS_SEL()) {
    -        if (*pSA > *pLen) {
    -            *pSA = *pLen;
    -        }
    -
    -        if (*pSC > *pLen) {
    -            *pSC = *pLen;
    -        }
    +    // A mouse click leaves a degenerate [p,p] range behind as the drag
    +    // anchor; treat it as "no selection" so SEL_BEGIN anchors at the
    +    // cursor instead of the stale click position.
    +    if (*pSA >= 0 && *pSA == *pSC) {
    +        *pSA = -1;
    +        *pSC = -1;
         }
     
         // Ctrl+A  -- select all
    @@ -1302,8 +1314,8 @@ void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLine
                     *pDesiredCol = *pCol;
                 }
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
     
                 textEditLineCacheDirty(lc);
    @@ -1329,8 +1341,8 @@ void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLine
                 *pSC = -1;
                 *pDesiredCol = *pCol;
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
     
                 textEditLineCacheDirty(lc);
    @@ -1380,8 +1392,8 @@ void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLine
                     *pSA = -1;
                     *pSC = -1;
     
    -                if (w->onChange) {
    -                    w->onChange(w);
    +                if (!widgetFireChange(w)) {
    +                    return;
                     }
     
                     textEditLineCacheDirty(lc);
    @@ -1431,8 +1443,8 @@ void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLine
                     *pDesiredCol = indent;
                 }
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
     
                 textEditLineCacheDirty(lc);
    @@ -1457,8 +1469,8 @@ void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLine
                 *pDesiredCol = *pCol;
                 textEditLineCacheDirty(lc);
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
             } else {
                 int32_t off = CUR_OFF();
    @@ -1477,8 +1489,8 @@ void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLine
                         textEditLineCacheNotifyDelete(lc, buf, *pLen, off - 1, 1);
                     }
     
    -                if (w->onChange) {
    -                    w->onChange(w);
    +                if (!widgetFireChange(w)) {
    +                    return;
                     }
                 }
             }
    @@ -1502,8 +1514,8 @@ void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLine
                 *pDesiredCol = *pCol;
                 textEditLineCacheDirty(lc);
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
             } else {
                 int32_t off = CUR_OFF();
    @@ -1520,8 +1532,8 @@ void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLine
                         textEditLineCacheNotifyDelete(lc, buf, *pLen, off, 1);
                     }
     
    -                if (w->onChange) {
    -                    w->onChange(w);
    +                if (!widgetFireChange(w)) {
    +                    return;
                     }
                 }
             }
    @@ -1784,12 +1796,10 @@ navigation: {
                     (*pCol)++;
                     *pDesiredCol = *pCol;
                     textEditLineCacheNotifyInsert(lc, buf, *pLen, off, 1);
    -            } else {
    -                textEditLineCacheDirty(lc);
                 }
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
             }
     
    @@ -2011,6 +2021,14 @@ void widgetTextEditMultiPaintArea(DisplayT *d, const BlitOpsT *ops, const Bitmap
     // This is the core single-line text editing engine, parameterized by
     // pointer to allow reuse across TextInput, Spinner, and ComboBox.
     void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_t bufSize, int32_t *pLen, int32_t *pCursor, int32_t *pScrollOff, int32_t *pSelStart, int32_t *pSelEnd, char *undoBuf, int32_t *pUndoLen, int32_t *pUndoCursor, int32_t fieldWidth) {
    +    // A mouse click leaves a degenerate [p,p] range behind as the drag
    +    // anchor.  Treat it as "no selection" here so a later Shift+arrow
    +    // anchors at the cursor, not at the stale click position.
    +    if (pSelStart && pSelEnd && *pSelStart >= 0 && *pSelStart == *pSelEnd) {
    +        *pSelStart = -1;
    +        *pSelEnd   = -1;
    +    }
    +
         bool shift   = (mod & KEY_MOD_SHIFT) != 0;
         bool hasSel  = (pSelStart && pSelEnd && *pSelStart >= 0 && *pSelEnd >= 0 && *pSelStart != *pSelEnd);
         int32_t selLo = hasSel ? (*pSelStart < *pSelEnd ? *pSelStart : *pSelEnd) : -1;
    @@ -2091,8 +2109,8 @@ void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_
                     *pCursor += paste;
                 }
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
             }
     
    @@ -2110,8 +2128,8 @@ void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_
     
                 textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
             }
     
    @@ -2153,8 +2171,8 @@ void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_
                 *pSelEnd = -1;
             }
     
    -        if (w->onChange) {
    -            w->onChange(w);
    +        if (!widgetFireChange(w)) {
    +            return;
             }
     
             goto adjustScroll;
    @@ -2179,8 +2197,8 @@ void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_
                 (*pLen)++;
                 (*pCursor)++;
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
             }
         } else if (key == 8) {
    @@ -2192,8 +2210,8 @@ void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_
     
                 textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
             } else if (*pCursor > 0) {
                 if (undoBuf) {
    @@ -2205,8 +2223,8 @@ void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_
                 (*pLen)--;
                 (*pCursor)--;
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
             }
         } else if (key == KEY_LEFT) {
    @@ -2352,8 +2370,8 @@ void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_
     
                 textEditDeleteSelection(buf, pLen, pCursor, pSelStart, pSelEnd);
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
             } else if (*pCursor < *pLen) {
                 if (undoBuf) {
    @@ -2364,8 +2382,8 @@ void widgetTextEditOnKey(WidgetT *w, int32_t key, int32_t mod, char *buf, int32_
                 memmove(buf + pos, buf + pos + 1, *pLen - pos);
                 (*pLen)--;
     
    -            if (w->onChange) {
    -                w->onChange(w);
    +            if (!widgetFireChange(w)) {
    +                return;
                 }
             }
         } else {
    @@ -2438,6 +2456,18 @@ void widgetTextEditPaintLine(DisplayT *d, const BlitOpsT *ops, const BitmapFontT
     }
     
     
    +// Converts a mouse coordinate (during an active thumb drag) to a
    +// clamped scroll value. dragOff is the thumb-relative offset captured
    +// when the drag started (from the hit-test's *pDragOff).
    +int32_t widgetTextScrollbarDragToScroll(bool vertical, int32_t mouseCoord, int32_t sbStart, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t dragOff) {
    +    (void)vertical;
    +    int32_t maxScroll = total - visible;
    +    int32_t trackLen  = len - thick * 2;
    +    int32_t rel       = mouseCoord - sbStart - thick - dragOff;
    +    return widgetScrollbarThumbDragScroll(trackLen, total, visible, rel, maxScroll);
    +}
    +
    +
     // Paints a single vertical or horizontal text-grid scrollbar:
     // trough, end-arrow buttons with direction triangles, and thumb.
     // All items are in logical units (lines/cols).
    @@ -2504,18 +2534,6 @@ void widgetTextScrollbarDraw(DisplayT *d, const BlitOpsT *ops, const ColorScheme
     }
     
     
    -// Converts a mouse coordinate (during an active thumb drag) to a
    -// clamped scroll value. dragOff is the thumb-relative offset captured
    -// when the drag started (from the hit-test's *pDragOff).
    -int32_t widgetTextScrollbarDragToScroll(bool vertical, int32_t mouseCoord, int32_t sbStart, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t dragOff) {
    -    (void)vertical;
    -    int32_t maxScroll = total - visible;
    -    int32_t trackLen  = len - thick * 2;
    -    int32_t rel       = mouseCoord - sbStart - thick - dragOff;
    -    return widgetScrollbarThumbDragScroll(trackLen, total, visible, rel, maxScroll);
    -}
    -
    -
     // Classifies a mouse click against a text-grid scrollbar. Returns
     // one of the TEXT_SB_HIT_* codes. Callers typically translate UP/
     // DOWN to single-step scrolls and PAGE_UP/PAGE_DOWN to page-sized
    @@ -2538,11 +2556,6 @@ int32_t widgetTextScrollbarHitTest(bool vertical, int32_t vx, int32_t vy, int32_
         }
     
         int32_t trackLen = len - thick * 2;
    -
    -    if (trackLen <= 0) {
    -        return TEXT_SB_HIT_NONE;
    -    }
    -
         int32_t thumbPos;
         int32_t thumbSize;
         widgetScrollbarThumb(trackLen, total, visible, scroll, &thumbPos, &thumbSize);
    diff --git a/src/libs/kpunch/texthelp/textHelp.h b/src/libs/kpunch/texthelp/textHelp.h
    index 4227b9e..f68f48f 100644
    --- a/src/libs/kpunch/texthelp/textHelp.h
    +++ b/src/libs/kpunch/texthelp/textHelp.h
    @@ -40,6 +40,9 @@
     
     void wgtUpdateCursorBlink(void);
     
    +// Test-only: reset module statics and re-register the blink hook.
    +void textHelpTestReset(void);
    +
     // ============================================================
     // Selection management
     // ============================================================
    @@ -89,13 +92,13 @@ typedef struct TextEditLineCacheT {
         int32_t  tabWidth;        // tab stop width used to compute lineVisLens
     } TextEditLineCacheT;
     
    -void    textEditLineCacheInit(TextEditLineCacheT *lc, int32_t tabWidth);
    -void    textEditLineCacheFree(TextEditLineCacheT *lc);
     void    textEditLineCacheDirty(TextEditLineCacheT *lc);
    -void    textEditLineCacheSetTabWidth(TextEditLineCacheT *lc, int32_t tabWidth);
     void    textEditLineCacheEnsure(TextEditLineCacheT *lc, const char *buf, int32_t len);
    -void    textEditLineCacheNotifyInsert(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t insertLen);
    +void    textEditLineCacheFree(TextEditLineCacheT *lc);
    +void    textEditLineCacheInit(TextEditLineCacheT *lc, int32_t tabWidth);
     void    textEditLineCacheNotifyDelete(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t deleteLen);
    +void    textEditLineCacheNotifyInsert(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t off, int32_t insertLen);
    +void    textEditLineCacheSetTabWidth(TextEditLineCacheT *lc, int32_t tabWidth);
     int32_t textEditLineCount(TextEditLineCacheT *lc, const char *buf, int32_t len);
     int32_t textEditLineLen(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t row);
     int32_t textEditLineStart(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t row);
    @@ -120,8 +123,8 @@ int32_t textEditRowColToOff(TextEditLineCacheT *lc, const char *buf, int32_t len
     #define TEXT_SYNTAX_TYPE        6
     #define TEXT_SYNTAX_MAX         7
     
    -uint32_t textEditSyntaxColor(const DisplayT *d, uint8_t idx, uint32_t defaultFg, const uint32_t *custom);
     void     textEditDrawColorizedText(DisplayT *d, const BlitOpsT *ops, const BitmapFontT *font, int32_t x, int32_t y, const char *text, int32_t len, const uint8_t *syntaxColors, int32_t textOff, uint32_t defaultFg, uint32_t bg, const uint32_t *customColors);
    +uint32_t textEditSyntaxColor(const DisplayT *d, uint8_t idx, uint32_t defaultFg, const uint32_t *custom);
     
     // ============================================================
     // Scroll adjustment
    @@ -172,8 +175,8 @@ void widgetTextEditMultiPaintArea(DisplayT *d, const BlitOpsT *ops, const Bitmap
     //
     // widgetTextEditMultiDragUpdateArea is called during a drag to
     // auto-scroll the view and extend the selection.
    -void widgetTextEditMultiMouseClick(WidgetT *w, TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t vx, int32_t vy, int32_t textX, int32_t textY, const BitmapFontT *font, int32_t scrollRow, int32_t scrollCol, int32_t tabW, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor);
     void widgetTextEditMultiDragUpdateArea(TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t vx, int32_t vy, int32_t textX, int32_t textY, const BitmapFontT *font, int32_t *pScrollRow, int32_t scrollCol, int32_t visRows, int32_t tabW, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelCursor);
    +void widgetTextEditMultiMouseClick(WidgetT *w, TextEditLineCacheT *lc, const char *buf, int32_t len, int32_t vx, int32_t vy, int32_t textX, int32_t textY, const BitmapFontT *font, int32_t scrollRow, int32_t scrollCol, int32_t tabW, int32_t *pCursorRow, int32_t *pCursorCol, int32_t *pDesiredCol, int32_t *pSelAnchor, int32_t *pSelCursor);
     
     // ============================================================
     // Multi-line key handler
    @@ -231,8 +234,8 @@ void widgetTextEditMultiOnKey(WidgetT *w, int32_t key, int32_t mod, TextEditLine
     #define TEXT_SB_HIT_PAGE_DOWN  4
     #define TEXT_SB_HIT_THUMB      5
     
    -void    widgetTextScrollbarDraw(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, bool vertical, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll);
     int32_t widgetTextScrollbarDragToScroll(bool vertical, int32_t mouseCoord, int32_t sbStart, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t dragOff);
    +void    widgetTextScrollbarDraw(DisplayT *d, const BlitOpsT *ops, const ColorSchemeT *colors, bool vertical, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll);
     int32_t widgetTextScrollbarHitTest(bool vertical, int32_t vx, int32_t vy, int32_t x, int32_t y, int32_t thick, int32_t len, int32_t total, int32_t visible, int32_t scroll, int32_t *pDragOff);
     
     // ============================================================
    @@ -269,8 +272,8 @@ typedef struct TextEditMultiT {
         TextEditLineCacheT lines;
     } TextEditMultiT;
     
    -bool textEditMultiInit(TextEditMultiT *te, int32_t maxLen, int32_t tabWidth);
     void textEditMultiFree(TextEditMultiT *te);
    +bool textEditMultiInit(TextEditMultiT *te, int32_t maxLen, int32_t tabWidth);
     
     // ============================================================
     // Multi-line text operations
    @@ -282,7 +285,8 @@ void textEditMultiFree(TextEditMultiT *te);
     //
     // textEditFindNext: searches forward/backward from the current
     // cursor position. If found, selects the match and moves the cursor
    -// to the match's start (forward) or end (backward). Returns true
    +// to the match's start, so repeating the search in either direction
    +// advances past the match instead of re-finding it. Returns true
     // on match, false on no-match (no wrap-around).
     //
     // textEditReplaceAll: replaces every occurrence of needle with
    diff --git a/src/loader/Makefile b/src/loader/Makefile
    index 9ea1df3..a44c6fb 100644
    --- a/src/loader/Makefile
    +++ b/src/loader/Makefile
    @@ -24,7 +24,9 @@
     #
     # Builds the bootstrap loader (dvx.exe) that loads DXE modules.
     # Links dvxPlatformDos.c directly -- the platform layer provides
    -# the DXE export table via platformRegisterDxeExports().
    +# the DXE export table via platformRegisterDxeExports().  dvxPlatformUtil.c
    +# (logging, glob, path helpers) and dvxMemTrack.c (per-app allocation
    +# tracking) are the backend-independent halves of the platform layer.
     
     DJGPP_PREFIX  = $(HOME)/djgpp/djgpp
     DJGPP_LIBPATH = $(HOME)/claude/windriver/tools/lib
    @@ -40,7 +42,7 @@ BINDIR    = ../../bin
     
     SRCS   = loaderMain.c
     OBJS   = $(patsubst %.c,$(OBJDIR)/%.o,$(SRCS))
    -POBJS  = $(POBJDIR)/dvxPlatformDos.o $(POBJDIR)/dvxPlatformUtil.o $(POBJDIR)/dvxPrefs.o $(OBJDIR)/dvxhlpc.o
    +POBJS  = $(POBJDIR)/dvxPlatformDos.o $(POBJDIR)/dvxPlatformUtil.o $(POBJDIR)/dvxMemTrack.o $(POBJDIR)/dvxPrefs.o $(OBJDIR)/dvxhlpc.o
     TARGET = $(BINDIR)/dvx.exe
     
     .PHONY: all clean
    @@ -62,6 +64,9 @@ $(POBJDIR)/dvxPlatformDos.o: ../libs/kpunch/libdvx/platform/dvxPlatformDos.c | $
     $(POBJDIR)/dvxPlatformUtil.o: ../libs/kpunch/libdvx/platform/dvxPlatformUtil.c | $(POBJDIR)
     	$(CC) $(CFLAGS) -c -o $@ $<
     
    +$(POBJDIR)/dvxMemTrack.o: ../libs/kpunch/libdvx/platform/dvxMemTrack.c | $(POBJDIR)
    +	$(CC) $(CFLAGS) -c -o $@ $<
    +
     $(POBJDIR)/dvxPrefs.o: ../libs/kpunch/libdvx/dvxPrefs.c | $(POBJDIR)
     	$(CC) $(CFLAGS) -c -o $@ $<
     
    @@ -81,6 +86,7 @@ $(BINDIR):
     $(OBJDIR)/loaderMain.o: loaderMain.c ../libs/kpunch/libdvx/platform/dvxPlat.h ../libs/kpunch/libdvx/dvxTypes.h
     $(POBJDIR)/dvxPlatformDos.o: ../libs/kpunch/libdvx/platform/dvxPlatformDos.c ../libs/kpunch/libdvx/platform/dvxPlat.h ../libs/kpunch/libdvx/dvxTypes.h ../libs/kpunch/libdvx/dvxPal.h
     $(POBJDIR)/dvxPlatformUtil.o: ../libs/kpunch/libdvx/platform/dvxPlatformUtil.c ../libs/kpunch/libdvx/platform/dvxPlat.h ../libs/kpunch/libdvx/dvxTypes.h
    +$(POBJDIR)/dvxMemTrack.o: ../libs/kpunch/libdvx/platform/dvxMemTrack.c ../libs/kpunch/libdvx/platform/dvxPlat.h ../libs/kpunch/libdvx/dvxMem.h
     $(POBJDIR)/dvxPrefs.o: ../libs/kpunch/libdvx/dvxPrefs.c ../libs/kpunch/libdvx/dvxPrefs.h
     
     clean:
    diff --git a/src/loader/loaderMain.c b/src/loader/loaderMain.c
    index 22ecdf0..f64d9a4 100644
    --- a/src/loader/loaderMain.c
    +++ b/src/loader/loaderMain.c
    @@ -46,7 +46,7 @@
     #include 
     #include 
     
    -// The loader is not a DXE — use plain realloc/free for stb_ds so that
    +// The loader is not a DXE -- use plain realloc/free for stb_ds so that
     // all translation units (loaderMain.o, dvxPrefs.o) share the same heap.
     #define STB_DS_IMPLEMENTATION
     #include "stb_ds_wrap.h"
    @@ -86,7 +86,7 @@ typedef struct {
         void  *handle;
     } ModuleT;
     
    -// Scratch state for processHcf callback — collects output=, imagedir=, and
    +// Scratch state for processHcf callback -- collects output=, imagedir=, and
     // the accumulated source= glob expansions in one pass.
     typedef struct {
         const char  *hcfDir;
    @@ -275,24 +275,6 @@ static int32_t countTotalHelpSteps(const char *dirPath) {
     }
     
     
    -// Global logging function exported to all DXE modules.
    -// Opens/closes the file per-write so it's never held open.
    -void dvxLog(const char *fmt, ...) {
    -    FILE *f = fopen(LOG_PATH, "a");
    -
    -    if (!f) {
    -        return;
    -    }
    -
    -    va_list ap;
    -    va_start(ap, fmt);
    -    vfprintf(f, fmt, ap);
    -    va_end(ap);
    -    fprintf(f, "\n");
    -    fclose(f);
    -}
    -
    -
     static void extractBaseName(const char *path, const char *ext, char *out, int32_t outSize) {
         // Find last directory separator
         const char *start = path;
    @@ -486,7 +468,7 @@ static void helpRecompileIfNeeded(void) {
     }
     
     
    -// Progress callback for hlpcCompile — updates the splash progress bar.
    +// Progress callback for hlpcCompile -- updates the splash progress bar.
     static void hlpcProgressCallback(void *ctx, int32_t current, int32_t total) {
         (void)ctx;
         (void)total;
    @@ -563,7 +545,7 @@ static void loadInOrder(ModuleT *mods) {
                 }
     
                 dvxLog("Loading: %s", mods[i].path);
    -            mods[i].handle = dlopen(mods[i].path, RTLD_GLOBAL);
    +            mods[i].handle = dlopen(mods[i].path, RTLD_NOW | RTLD_GLOBAL);
     
                 if (!mods[i].handle) {
                     const char *err = dlerror();
    @@ -573,7 +555,7 @@ static void loadInOrder(ModuleT *mods) {
                     exit(1);
                 }
     
    -            RegFnT regFn = (RegFnT)dlsym(mods[i].handle, "_wgtRegister");
    +            RegFnT regFn = (RegFnT)dlsym(mods[i].handle, DVX_SYM("wgtRegister"));
     
                 if (regFn) {
                     regFn();
    @@ -584,10 +566,10 @@ static void loadInOrder(ModuleT *mods) {
                     typedef const char *(*IfaceGetPathFnT)(const char *);
                     typedef void (*IfaceSetPathFnT)(const char *, const char *);
     
    -                IfaceCountFnT   countFn   = (IfaceCountFnT)dlsym(NULL, "_wgtIfaceCount");
    -                IfaceAtFnT      atFn      = (IfaceAtFnT)dlsym(NULL, "_wgtIfaceAt");
    -                IfaceGetPathFnT getPathFn = (IfaceGetPathFnT)dlsym(NULL, "_wgtIfaceGetPath");
    -                IfaceSetPathFnT setPathFn = (IfaceSetPathFnT)dlsym(NULL, "_wgtIfaceSetPath");
    +                IfaceCountFnT   countFn   = (IfaceCountFnT)dlsym(NULL, DVX_SYM("wgtIfaceCount"));
    +                IfaceAtFnT      atFn      = (IfaceAtFnT)dlsym(NULL, DVX_SYM("wgtIfaceAt"));
    +                IfaceGetPathFnT getPathFn = (IfaceGetPathFnT)dlsym(NULL, DVX_SYM("wgtIfaceGetPath"));
    +                IfaceSetPathFnT setPathFn = (IfaceSetPathFnT)dlsym(NULL, DVX_SYM("wgtIfaceSetPath"));
     
                     if (countFn && atFn && getPathFn && setPathFn) {
                         int32_t ic = countFn();
    @@ -656,60 +638,6 @@ static void logAndReadDeps(ModuleT *mods) {
     }
     
     
    -// Simple glob pattern matching for filenames. Supports:
    -//   *       matches zero or more characters
    -//   ?       matches exactly one character
    -//   !pat    at the start means "exclude files matching pat"
    -// Case-insensitive. Exported for use by DXE modules.
    -bool platformGlobMatch(const char *pattern, const char *name) {
    -    while (*pattern && *name) {
    -        if (*pattern == '*') {
    -            pattern++;
    -
    -            if (!*pattern) {
    -                return true;
    -            }
    -
    -            while (*name) {
    -                if (platformGlobMatch(pattern, name)) {
    -                    return true;
    -                }
    -
    -                name++;
    -            }
    -
    -            return false;
    -        } else if (*pattern == '?' || tolower((unsigned char)*pattern) == tolower((unsigned char)*name)) {
    -            pattern++;
    -            name++;
    -        } else {
    -            return false;
    -        }
    -    }
    -
    -    // Skip trailing *
    -    while (*pattern == '*') {
    -        pattern++;
    -    }
    -
    -    return *pattern == '\0' && *name == '\0';
    -}
    -
    -
    -// Callback for processHcf: accumulate output=, imagedir=, and expanded source= entries.
    -static void processHcfCallback(const char *key, const char *val, const char *exclude, void *user) {
    -    ProcessHcfCtxT *ctx = (ProcessHcfCtxT *)user;
    -
    -    if (strcasecmp(key, "output") == 0) {
    -        snprintf(ctx->outputFile, sizeof(ctx->outputFile), "%s" DVX_PATH_SEP "%s", ctx->hcfDir, val);
    -    } else if (strcasecmp(key, "imagedir") == 0) {
    -        snprintf(ctx->imgDir, sizeof(ctx->imgDir), "%s", val);
    -    } else if (strcasecmp(key, "source") == 0) {
    -        collectGlobFiles(&ctx->inputFiles, val, exclude);
    -    }
    -}
    -
    -
     static void processHcf(const char *hcfPath, const char *hcfDir) {
         ProcessHcfCtxT ctx;
         memset(&ctx, 0, sizeof(ctx));
    @@ -743,6 +671,20 @@ static void processHcf(const char *hcfPath, const char *hcfDir) {
     }
     
     
    +// Callback for processHcf: accumulate output=, imagedir=, and expanded source= entries.
    +static void processHcfCallback(const char *key, const char *val, const char *exclude, void *user) {
    +    ProcessHcfCtxT *ctx = (ProcessHcfCtxT *)user;
    +
    +    if (strcasecmp(key, "output") == 0) {
    +        snprintf(ctx->outputFile, sizeof(ctx->outputFile), "%s" DVX_PATH_SEP "%s", ctx->hcfDir, val);
    +    } else if (strcasecmp(key, "imagedir") == 0) {
    +        snprintf(ctx->imgDir, sizeof(ctx->imgDir), "%s", val);
    +    } else if (strcasecmp(key, "source") == 0) {
    +        collectGlobFiles(&ctx->inputFiles, val, exclude);
    +    }
    +}
    +
    +
     // Recursively scan a directory for .hcf files and process each one.
     static void processHcfDir(const char *dirPath) {
         char **names = dvxReadDir(dirPath);
    @@ -977,6 +919,7 @@ int main(int argc, char *argv[]) {
         }
     
         // Suppress Ctrl+C before anything else
    +    platformSetLogPath(LOG_PATH);
         platformInit();
     
         // Switch to VGA mode 13h and show graphical splash
    @@ -1007,7 +950,7 @@ int main(int argc, char *argv[]) {
     
         // Find and call shellMain from whichever module exports it
         typedef int (*ShellMainFnT)(int, char **);
    -    ShellMainFnT shellMain = (ShellMainFnT)findSymbol(handles, "_shellMain");
    +    ShellMainFnT shellMain = (ShellMainFnT)findSymbol(handles, DVX_SYM("shellMain"));
     
         if (!shellMain) {
             dvxLog("ERROR: No module exports shellMain");
    diff --git a/src/test/basic/Makefile b/src/test/basic/Makefile
    new file mode 100644
    index 0000000..ddc615a
    --- /dev/null
    +++ b/src/test/basic/Makefile
    @@ -0,0 +1,124 @@
    +# 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.
    +
    +# src/test/basic -- host-side BASIC end-to-end tests (suite T7)
    +#
    +#   make test            headless sample/fixture runs, compaction
    +#                        differential, compiler fuzz, bytecode fuzz
    +#   make test TESTSAN=1  same with the fuzzers built under ASan/UBSan
    +#                        (bin/host/basrun and bascomp come from the
    +#                        dvxbasic Makefile, which honours TESTSAN too)
    +#   make fuzz COV=1      fuzzers built with gcov instrumentation under
    +#                        obj/hostcov (same split as the dvxbasic Makefile)
    +#
    +# Tunables: FUZZ_N (mutations per seed), FUZZ_SEED, FUZZ_STEPS.
    +
    +HOSTCC      = gcc
    +WARNFLAGS   = -Wall -Wextra -Werror -Wno-type-limits -Wno-sign-compare -Wno-format-truncation -Wno-stringop-truncation
    +DVXBASIC    = ../../apps/kpunch/dvxbasic
    +LIBDVX      = ../../libs/kpunch/libdvx
    +INCLUDES    = -I$(DVXBASIC) -I$(LIBDVX) -I$(LIBDVX)/platform -I$(LIBDVX)/thirdparty
    +HOSTCFLAGS  = -O2 -g $(WARNFLAGS) -D_GNU_SOURCE $(INCLUDES)
    +ifeq ($(TESTSAN),1)
    +HOSTCFLAGS += -fno-omit-frame-pointer -fsanitize=address,undefined
    +endif
    +
    +REPO        = ../../..
    +# COV=1: instrumented binaries and their scratch tree live under
    +# obj/hostcov so they never mix with the sanitized or release builds.
    +ifeq ($(COV),1)
    +HOSTCFLAGS += --coverage
    +HOSTDIR     = $(REPO)/obj/hostcov/basic
    +OUTDIR      = $(REPO)/obj/hostcov/test/basic
    +else
    +HOSTDIR     = $(REPO)/bin/host
    +OUTDIR      = $(REPO)/obj/test/basic
    +endif
    +CORPUS      = $(abspath $(OUTDIR)/corpus)
    +FUZZWORK    = $(OUTDIR)/fuzzwork
    +GOLDEN      = $(abspath $(REPO)/src/test/samples/golden)
    +
    +FUZZ_N      = 10
    +FUZZ_SEED   = 12345
    +FUZZ_STEPS  = 200000
    +
    +# Corrupt length fields legitimately ask for huge allocations; the
    +# deserializer must handle a NULL from malloc, so let ASan return one
    +# instead of aborting the run.
    +ASAN_ENV    = ASAN_OPTIONS=allocator_may_return_null=1:detect_leaks=1
    +
    +COMPILER_SRCS = $(DVXBASIC)/compiler/lexer.c $(DVXBASIC)/compiler/parser.c $(DVXBASIC)/compiler/codegen.c $(DVXBASIC)/compiler/symtab.c
    +RUNTIME_SRCS  = $(DVXBASIC)/runtime/vm.c $(DVXBASIC)/runtime/values.c $(DVXBASIC)/runtime/serialize.c
    +SUPPORT_SRCS  = $(LIBDVX)/platform/dvxPlatformUtil.c $(LIBDVX)/thirdparty/stb_ds_impl.c
    +HARNESS_HDRS  = $(wildcard $(DVXBASIC)/*.h $(DVXBASIC)/compiler/*.h $(DVXBASIC)/runtime/*.h) fuzzCommon.h
    +
    +FUZZ_COMPILE_SRCS = fuzzCompile.c fuzzCommon.c $(COMPILER_SRCS) $(RUNTIME_SRCS) $(SUPPORT_SRCS)
    +FUZZ_MODULE_SRCS  = fuzzModule.c fuzzCommon.c $(COMPILER_SRCS) $(RUNTIME_SRCS) $(LIBDVX)/dvxResource.c $(SUPPORT_SRCS)
    +
    +FUZZ_COMPILE = $(abspath $(HOSTDIR)/fuzzCompile)
    +FUZZ_MODULE  = $(abspath $(HOSTDIR)/fuzzModule)
    +
    +HARNESS_SRCS = $(DVXBASIC)/test_suite.c $(DVXBASIC)/test_compiler.c $(DVXBASIC)/test_compact.c
    +
    +TESTSAN_STAMP = $(OUTDIR)/.testsan-$(if $(filter 1,$(TESTSAN)),on,off)
    +
    +.PHONY: all build test corpus runs fuzz clean
    +
    +all: build
    +
    +build: $(FUZZ_COMPILE) $(FUZZ_MODULE)
    +
    +test: runs fuzz
    +
    +# basrun-driven sample / fixture / compaction runs
    +runs:
    +	./runBasic.sh
    +
    +# Regenerate the seed corpus from the C harnesses every time: cheap,
    +# and it can never go stale.
    +corpus: | $(OUTDIR)
    +	rm -rf $(CORPUS)
    +	./extractCorpus.py $(CORPUS) $(HARNESS_SRCS)
    +
    +# Fuzzers run inside a scratch directory so file-system statements in
    +# mutated programs only ever touch files there.
    +fuzz: build corpus
    +	rm -rf $(FUZZWORK)
    +	mkdir -p $(FUZZWORK)
    +	cd $(FUZZWORK) && $(ASAN_ENV) $(FUZZ_COMPILE) -n $(FUZZ_N) -seed $(FUZZ_SEED) -steps $(FUZZ_STEPS) $(CORPUS)
    +	cd $(FUZZWORK) && $(ASAN_ENV) $(FUZZ_MODULE) -n $(FUZZ_N) -seed $(FUZZ_SEED) -steps $(FUZZ_STEPS) -corpus $(CORPUS) $(wildcard $(GOLDEN)/*.app)
    +
    +$(FUZZ_COMPILE): $(FUZZ_COMPILE_SRCS) $(HARNESS_HDRS) $(TESTSAN_STAMP) | $(HOSTDIR)
    +	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(FUZZ_COMPILE_SRCS) -lm
    +
    +$(FUZZ_MODULE): $(FUZZ_MODULE_SRCS) $(HARNESS_HDRS) $(TESTSAN_STAMP) | $(HOSTDIR)
    +	$(HOSTCC) $(HOSTCFLAGS) -o $@ $(FUZZ_MODULE_SRCS) -lm
    +
    +$(TESTSAN_STAMP): | $(OUTDIR)
    +	rm -f $(OUTDIR)/.testsan-on $(OUTDIR)/.testsan-off
    +	touch $@
    +
    +$(HOSTDIR) $(OUTDIR):
    +	mkdir -p $@
    +
    +clean:
    +	rm -rf $(OUTDIR) $(FUZZ_COMPILE) $(FUZZ_MODULE)
    diff --git a/src/test/basic/extractCorpus.py b/src/test/basic/extractCorpus.py
    new file mode 100755
    index 0000000..7bf30cd
    --- /dev/null
    +++ b/src/test/basic/extractCorpus.py
    @@ -0,0 +1,226 @@
    +#!/usr/bin/env python3
    +# 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.
    +
    +"""extractCorpus.py -- pull BASIC test programs out of the C harnesses.
    +
    +Every TEST_EQ / TEST_COMPILE_ERROR / TEST_RUNTIME_ERROR / TEST_SUB_EQ /
    +runProgram / expectCompileError / testCompact call whose source argument
    +is a plain (possibly concatenated) C string literal becomes one .bas
    +file in the output directory.  The corpus is the seed set for
    +fuzzCompile and fuzzModule; it is generated at test time so the C
    +harnesses stay the single source of truth.
    +
    +Usage: extractCorpus.py OUTDIR HARNESS.c [HARNESS.c ...]
    +"""
    +
    +import os
    +import re
    +import sys
    +
    +CALL_NAMES = {
    +    "TEST_EQ", "TEST_COMPILE_ERROR", "TEST_RUNTIME_ERROR", "TEST_SUB_EQ",
    +    "runProgram", "expectCompileError", "testCompact",
    +}
    +
    +CALL_RE = re.compile(r"\b(" + "|".join(sorted(CALL_NAMES)) + r")\s*\(")
    +
    +ESCAPES = {
    +    "n": "\n", "t": "\t", "r": "\r", "\\": "\\", '"': '"', "'": "'",
    +    "0": "\0", "a": "\a", "b": "\b", "f": "\f", "v": "\v", "?": "?",
    +}
    +
    +
    +def unescape(body):
    +    out = []
    +    i = 0
    +
    +    while i < len(body):
    +        c = body[i]
    +
    +        if c != "\\":
    +            out.append(c)
    +            i += 1
    +            continue
    +
    +        i += 1
    +        e = body[i]
    +
    +        if e == "x":
    +            j = i + 1
    +
    +            while j < len(body) and body[j] in "0123456789abcdefABCDEF":
    +                j += 1
    +
    +            out.append(chr(int(body[i + 1:j], 16)))
    +            i = j
    +        elif e in "01234567":
    +            j = i
    +
    +            while j < len(body) and j < i + 3 and body[j] in "01234567":
    +                j += 1
    +
    +            out.append(chr(int(body[i:j], 8)))
    +            i = j
    +        else:
    +            out.append(ESCAPES.get(e, e))
    +            i += 1
    +
    +    return "".join(out)
    +
    +
    +def splitArgs(text, start):
    +    """Split the argument list starting after '(' at text[start].
    +    Returns (list of raw argument strings, index after ')')."""
    +    depth = 0
    +    args = []
    +    cur = []
    +    i = start
    +    inStr = False
    +
    +    while i < len(text):
    +        c = text[i]
    +
    +        if inStr:
    +            cur.append(c)
    +
    +            if c == "\\":
    +                cur.append(text[i + 1])
    +                i += 2
    +                continue
    +
    +            if c == '"':
    +                inStr = False
    +
    +            i += 1
    +            continue
    +
    +        if c == '"':
    +            inStr = True
    +            cur.append(c)
    +        elif c == "(":
    +            depth += 1
    +            cur.append(c)
    +        elif c == ")":
    +            if depth == 0:
    +                args.append("".join(cur))
    +                return args, i + 1
    +
    +            depth -= 1
    +            cur.append(c)
    +        elif c == "," and depth == 0:
    +            args.append("".join(cur))
    +            cur = []
    +        else:
    +            cur.append(c)
    +
    +        i += 1
    +
    +    return None, i
    +
    +
    +LITERAL_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
    +
    +
    +def literalValue(arg):
    +    """Concatenate adjacent string literals; None if arg is not purely literals."""
    +    rest = arg.strip()
    +    parts = []
    +
    +    while rest:
    +        m = LITERAL_RE.match(rest)
    +
    +        if not m:
    +            return None
    +
    +        parts.append(unescape(m.group(1)))
    +        rest = rest[m.end():].lstrip()
    +
    +    if not parts:
    +        return None
    +
    +    return "".join(parts)
    +
    +
    +def stripComments(text):
    +    text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
    +    return re.sub(r"//[^\n]*", "", text)
    +
    +
    +def safeName(name):
    +    return re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_")[:48] or "case"
    +
    +
    +def main():
    +    if len(sys.argv) < 3:
    +        print(__doc__)
    +        return 1
    +
    +    outDir = sys.argv[1]
    +    os.makedirs(outDir, exist_ok=True)
    +    count = 0
    +    seen = set()
    +
    +    for path in sys.argv[2:]:
    +        text = stripComments(open(path, encoding="ascii", errors="replace").read())
    +        prefix = os.path.splitext(os.path.basename(path))[0]
    +        pos = 0
    +
    +        while True:
    +            m = CALL_RE.search(text, pos)
    +
    +            if not m:
    +                break
    +
    +            args, pos = splitArgs(text, m.end())
    +
    +            if args is None:
    +                break
    +
    +            # Skip the #define lines: their "arguments" are macro params.
    +            if len(args) < 2:
    +                continue
    +
    +            name = literalValue(args[0])
    +            source = literalValue(args[1])
    +
    +            if name is None or source is None or not source.strip():
    +                continue
    +
    +            key = source
    +
    +            if key in seen:
    +                continue
    +
    +            seen.add(key)
    +            count += 1
    +            fileName = "%s-%04d-%s.bas" % (prefix, count, safeName(name))
    +
    +            with open(os.path.join(outDir, fileName), "w", encoding="latin-1") as f:
    +                f.write(source)
    +
    +    print("extractCorpus: %d programs written to %s" % (count, outDir))
    +    return 0
    +
    +
    +if __name__ == "__main__":
    +    sys.exit(main())
    diff --git a/src/test/basic/fixtures/arraysMulti.bas b/src/test/basic/fixtures/arraysMulti.bas
    new file mode 100644
    index 0000000..efed4bf
    --- /dev/null
    +++ b/src/test/basic/fixtures/arraysMulti.bas
    @@ -0,0 +1,35 @@
    +Dim m(2, 3) As Integer
    +Dim i As Integer
    +Dim j As Integer
    +For i = 0 To 2
    +    For j = 0 To 3
    +        m(i, j) = i * 10 + j
    +    Next j
    +Next i
    +Print m(2, 3); m(0, 1); m(1, 2)
    +Print LBound(m, 1); UBound(m, 1); LBound(m, 2); UBound(m, 2)
    +Dim c(1 To 2, 1 To 2, 1 To 2) As Long
    +c(2, 2, 2) = 888
    +c(1, 2, 1) = 121
    +Print c(2, 2, 2); c(1, 2, 1); c(1, 1, 1)
    +ReDim d(3) As String
    +d(3) = "three"
    +ReDim Preserve d(6)
    +d(6) = "six"
    +Print "[" & d(3) & "][" & d(6) & "]"; UBound(d)
    +ReDim Preserve d(1)
    +Print UBound(d); "[" & d(1) & "]"
    +ReDim d(2)
    +Print "[" & d(2) & "]"
    +Dim n(5) As Integer
    +n(5) = 5
    +On Error GoTo Oops
    +Erase n
    +Print n(5)
    +Print "after erase"
    +Print m(3, 0)
    +Print "after bounds"
    +End
    +Oops:
    +    Print "err"; Err
    +    Resume Next
    diff --git a/src/test/basic/fixtures/arraysMulti.out b/src/test/basic/fixtures/arraysMulti.out
    new file mode 100644
    index 0000000..16eb7b9
    --- /dev/null
    +++ b/src/test/basic/fixtures/arraysMulti.out
    @@ -0,0 +1,10 @@
    +23 1 12 
    +0 2 0 3 
    +888 121 0 
    +[three][six]6 
    +1 []
    +[]
    +0 
    +after erase
    +err9 
    +after bounds
    diff --git a/src/test/basic/fixtures/cintRounding.bas b/src/test/basic/fixtures/cintRounding.bas
    new file mode 100644
    index 0000000..9431af8
    --- /dev/null
    +++ b/src/test/basic/fixtures/cintRounding.bas
    @@ -0,0 +1,18 @@
    +Print CInt(2.5); CInt(3.5); CInt(-2.5); CInt(-3.5); CInt(0.5); CInt(1.5)
    +Print CInt(2.4999); CInt(2.5001); CInt(-0.5)
    +Print CLng(2.5); CLng(3.5); CLng(2147483646.5)
    +Print CInt(32767.4); CInt(-32768.4)
    +Print Int(-2.5); Fix(-2.5); Int(2.5); Fix(2.5)
    +Print CDbl(1) / 3
    +Print CSng(1) / 3
    +Print CBool(0); CBool(5); CBool(""); CBool("x")
    +Print CStr(12) & CStr(-3.5)
    +On Error GoTo Bad
    +Print CInt(32768)
    +Print CInt(-32769)
    +Print CLng(3000000000#)
    +Print "done"
    +End
    +Bad:
    +    Print "err"; Err
    +    Resume Next
    diff --git a/src/test/basic/fixtures/cintRounding.out b/src/test/basic/fixtures/cintRounding.out
    new file mode 100644
    index 0000000..0ec9fbf
    --- /dev/null
    +++ b/src/test/basic/fixtures/cintRounding.out
    @@ -0,0 +1,13 @@
    +2 4 -2 -4 0 2 
    +2 3 0 
    +2 4 2147483646 
    +32767 -32768 
    +-3 -2 2 2 
    +0.333333333333333 
    +0.333333333333333 
    +False True False True 
    +12-3.5
    +err6 
    +err6 
    +err6 
    +done
    diff --git a/src/test/basic/fixtures/compareText.bas b/src/test/basic/fixtures/compareText.bas
    new file mode 100644
    index 0000000..88f9d05
    --- /dev/null
    +++ b/src/test/basic/fixtures/compareText.bas
    @@ -0,0 +1,8 @@
    +Option Compare Text
    +Print "abc" = "ABC"; "a" < "B"; Instr("Hello", "L")
    +Select Case "HELLO"
    +    Case "hello"
    +        Print "matched"
    +    Case Else
    +        Print "missed"
    +End Select
    diff --git a/src/test/basic/fixtures/compareText.out b/src/test/basic/fixtures/compareText.out
    new file mode 100644
    index 0000000..2640406
    --- /dev/null
    +++ b/src/test/basic/fixtures/compareText.out
    @@ -0,0 +1,2 @@
    +True True 0 
    +matched
    diff --git a/src/test/basic/fixtures/constsAndLet.bas b/src/test/basic/fixtures/constsAndLet.bas
    new file mode 100644
    index 0000000..10db2ae
    --- /dev/null
    +++ b/src/test/basic/fixtures/constsAndLet.bas
    @@ -0,0 +1,13 @@
    +Const MAXV = 100
    +Const PI = 3.14159
    +Const APPNAME = "DVX"
    +Const FLAG = True
    +Const HDR As String = "== Report =="
    +Dim x As Integer
    +Let x = MAXV \ 3
    +Print x; MAXV; APPNAME; FLAG; HDR
    +Print Int(PI * 100)
    +Sub ShowIt()
    +    Print APPNAME & " in sub"; MAXV
    +End Sub
    +ShowIt
    diff --git a/src/test/basic/fixtures/constsAndLet.out b/src/test/basic/fixtures/constsAndLet.out
    new file mode 100644
    index 0000000..ecaa548
    --- /dev/null
    +++ b/src/test/basic/fixtures/constsAndLet.out
    @@ -0,0 +1,3 @@
    +33 100 DVXTrue == Report ==
    +314 
    +DVX in sub100 
    diff --git a/src/test/basic/fixtures/dataRead.bas b/src/test/basic/fixtures/dataRead.bas
    new file mode 100644
    index 0000000..b98651a
    --- /dev/null
    +++ b/src/test/basic/fixtures/dataRead.bas
    @@ -0,0 +1,25 @@
    +Dim a As Integer
    +Dim b As Double
    +Dim s As String
    +Dim i As Integer
    +Read a, b, s
    +Print a; b; s
    +For i = 1 To 3
    +    Read a
    +    Print a;
    +Next i
    +Print
    +Restore
    +Read a
    +Print a
    +Read b, s, a, a, a
    +Print a
    +On Error GoTo Done
    +Read a
    +Print "not here"
    +End
    +Done:
    +    Print "err"; Err
    +    End
    +Data 1, 2.5, "text", 10, 20
    +Data 30
    diff --git a/src/test/basic/fixtures/dataRead.out b/src/test/basic/fixtures/dataRead.out
    new file mode 100644
    index 0000000..1730462
    --- /dev/null
    +++ b/src/test/basic/fixtures/dataRead.out
    @@ -0,0 +1,5 @@
    +1 2.5 text
    +10 20 30 
    +1 
    +30 
    +err4 
    diff --git a/src/test/basic/fixtures/defTypes.bas b/src/test/basic/fixtures/defTypes.bas
    new file mode 100644
    index 0000000..806a034
    --- /dev/null
    +++ b/src/test/basic/fixtures/defTypes.bas
    @@ -0,0 +1,15 @@
    +DefInt I-K
    +DefLng L
    +DefSng S
    +DefDbl D
    +DefStr T
    +i = 7 / 2
    +Print i
    +l = 2147483647
    +Print l
    +s = 1 / 3
    +Print s
    +d = 1 / 3
    +Print d
    +t = "text"
    +Print t
    diff --git a/src/test/basic/fixtures/defTypes.out b/src/test/basic/fixtures/defTypes.out
    new file mode 100644
    index 0000000..22e33e3
    --- /dev/null
    +++ b/src/test/basic/fixtures/defTypes.out
    @@ -0,0 +1,5 @@
    +4 
    +2147483647 
    +0.3333333 
    +0.333333333333333 
    +text
    diff --git a/src/test/basic/fixtures/divByZero.bas b/src/test/basic/fixtures/divByZero.bas
    new file mode 100644
    index 0000000..23c039a
    --- /dev/null
    +++ b/src/test/basic/fixtures/divByZero.bas
    @@ -0,0 +1,3 @@
    +Print "before"
    +Print 1 \ 0
    +Print "after"
    diff --git a/src/test/basic/fixtures/divByZero.exit b/src/test/basic/fixtures/divByZero.exit
    new file mode 100644
    index 0000000..b4de394
    --- /dev/null
    +++ b/src/test/basic/fixtures/divByZero.exit
    @@ -0,0 +1 @@
    +11
    diff --git a/src/test/basic/fixtures/divByZero.out b/src/test/basic/fixtures/divByZero.out
    new file mode 100644
    index 0000000..90be1f3
    --- /dev/null
    +++ b/src/test/basic/fixtures/divByZero.out
    @@ -0,0 +1 @@
    +before
    diff --git a/src/test/basic/fixtures/erlLine.bas b/src/test/basic/fixtures/erlLine.bas
    new file mode 100644
    index 0000000..53b7ee5
    --- /dev/null
    +++ b/src/test/basic/fixtures/erlLine.bas
    @@ -0,0 +1,11 @@
    +' ERL reports the line of the current error and resets after RESUME.
    +On Error GoTo Handler
    +Dim v As Integer
    +v = 1 / 0
    +Print "after"; ERL
    +Error 7
    +Print "end"; ERL
    +End
    +Handler:
    +    Print Err; ERL
    +    Resume Next
    diff --git a/src/test/basic/fixtures/erlLine.out b/src/test/basic/fixtures/erlLine.out
    new file mode 100644
    index 0000000..749ca9f
    --- /dev/null
    +++ b/src/test/basic/fixtures/erlLine.out
    @@ -0,0 +1,4 @@
    +11 4 
    +after0 
    +7 6 
    +end0 
    diff --git a/src/test/basic/fixtures/fileBinary.bas b/src/test/basic/fixtures/fileBinary.bas
    new file mode 100644
    index 0000000..c22307f
    --- /dev/null
    +++ b/src/test/basic/fixtures/fileBinary.bas
    @@ -0,0 +1,38 @@
    +Dim f As String
    +Dim buf As String
    +Dim i As Integer
    +Dim l As Long
    +Dim d As Double
    +f = App.Data & "/raw.bin"
    +Open f For Binary As #1
    +Put #1, 1, "ABCDEFGH"
    +i = 4660
    +Put #1, 9, i
    +l = 305419896
    +Put #1, , l
    +d = 2.5
    +Put #1, , d
    +Close #1
    +Print "size:"; FileLen(f)
    +Open f For Binary As #2
    +buf = Space$(4)
    +Get #2, 1, buf
    +Print buf
    +Get #2, 5, buf
    +Print buf
    +i = 0
    +Get #2, 9, i
    +Print i
    +l = 0
    +Get #2, , l
    +Print l
    +d = 0
    +Get #2, , d
    +Print d
    +Print "loc:"; Loc(2); "lof:"; Lof(2); "eof:"; Eof(2)
    +Seek #2, 3
    +buf = Space$(2)
    +Get #2, , buf
    +Print buf; Seek(2)
    +Close #2
    +Kill f
    diff --git a/src/test/basic/fixtures/fileBinary.out b/src/test/basic/fixtures/fileBinary.out
    new file mode 100644
    index 0000000..6b4d002
    --- /dev/null
    +++ b/src/test/basic/fixtures/fileBinary.out
    @@ -0,0 +1,8 @@
    +size:22 
    +ABCD
    +EFGH
    +4660 
    +305419896 
    +2.5 
    +loc:22 lof:22 eof:True 
    +CD5 
    diff --git a/src/test/basic/fixtures/fileInputMulti.bas b/src/test/basic/fixtures/fileInputMulti.bas
    new file mode 100644
    index 0000000..8fc13ed
    --- /dev/null
    +++ b/src/test/basic/fixtures/fileInputMulti.bas
    @@ -0,0 +1,34 @@
    +Dim f As String
    +Dim a As String
    +Dim b As Integer
    +Dim c As Double
    +Dim d As String
    +f = App.Data & "/data.txt"
    +Open f For Output As #1
    +Write #1, "Scott", 42, 3.25
    +Write #1, "Jane, Doe", -7, 0.5
    +Print #1, "plain,12,1.75"
    +Close #1
    +Open f For Input As #1
    +Input #1, a, b, c
    +Print a; b; c
    +Input #1, a, b, c
    +Print a; b; c
    +Input #1, a, b, c
    +Print a; b; c
    +Print "eof:"; Eof(1)
    +Close #1
    +Open f For Input As #1
    +Line Input #1, d
    +Print d
    +Line Input #1, d
    +Print d
    +Close #1
    +Open f For Append As #2
    +Print #2, "tail"
    +Close #2
    +Open f For Input As #3
    +Print Input$(5, #3)
    +Close #3
    +Print "free:"; FreeFile
    +Kill f
    diff --git a/src/test/basic/fixtures/fileInputMulti.out b/src/test/basic/fixtures/fileInputMulti.out
    new file mode 100644
    index 0000000..cba7fe4
    --- /dev/null
    +++ b/src/test/basic/fixtures/fileInputMulti.out
    @@ -0,0 +1,8 @@
    +Scott42 3.25 
    +Jane, Doe-7 0.5 
    +plain12 1.75 
    +eof:True 
    +"Scott",42,3.25
    +"Jane, Doe",-7,0.5
    +"Scot
    +free:1 
    diff --git a/src/test/basic/fixtures/fileRandom.bas b/src/test/basic/fixtures/fileRandom.bas
    new file mode 100644
    index 0000000..f176472
    --- /dev/null
    +++ b/src/test/basic/fixtures/fileRandom.bas
    @@ -0,0 +1,41 @@
    +Dim i As Integer
    +Dim l As Long
    +Dim d As Double
    +Dim s As String
    +Dim f As String
    +f = App.Data & "/recs.dat"
    +Open f For Random As #1 Len = 16
    +i = 1234
    +Put #1, 1, i
    +l = 123456789
    +Put #1, 2, l
    +d = 9.75
    +Put #1, 3, d
    +s = "record4"
    +Put #1, 4, s
    +Close #1
    +Print "size:"; FileLen(f)
    +Open f For Random As #1 Len = 16
    +i = 0
    +l = 0
    +d = 0
    +s = ""
    +Get #1, 4, s
    +Get #1, 3, d
    +Get #1, 2, l
    +Get #1, 1, i
    +Print i; l; d; s
    +Print "lof:"; Lof(1)
    +Close #1
    +Open f For Random As #2
    +Put #2, 2, "default len"
    +Close #2
    +Print "size:"; FileLen(f)
    +Kill f
    +On Error GoTo Bad
    +Open f For Random As #3 Len = 0
    +Print "not reached"
    +End
    +Bad:
    +    Print "err"; Err
    +    End
    diff --git a/src/test/basic/fixtures/fileRandom.out b/src/test/basic/fixtures/fileRandom.out
    new file mode 100644
    index 0000000..0257f3a
    --- /dev/null
    +++ b/src/test/basic/fixtures/fileRandom.out
    @@ -0,0 +1,5 @@
    +size:64 
    +1234 123456789 9.75 record4
    +lof:64 
    +size:256 
    +err59 
    diff --git a/src/test/basic/fixtures/fileRandomUdt.bas b/src/test/basic/fixtures/fileRandomUdt.bas
    new file mode 100644
    index 0000000..5f50cf1
    --- /dev/null
    +++ b/src/test/basic/fixtures/fileRandomUdt.bas
    @@ -0,0 +1,53 @@
    +' GET/PUT with TYPE variables: packed fields, fixed strings padded,
    +' variable strings length-prefixed, RANDOM records padded to LEN.
    +Type PointT
    +    x As Integer
    +    y As Integer
    +End Type
    +Type RecT
    +    id As Integer
    +    score As Double
    +    nm As String * 6
    +    tag As String
    +    pos As PointT
    +    ok As Boolean
    +End Type
    +Dim r As RecT
    +Dim q As RecT
    +Dim f As String
    +f = App.Data & "/udt.dat"
    +Open f For Random As #1 Len = 32
    +r.id = 1
    +r.score = 1.25
    +r.nm = "ab"
    +r.tag = "hello"
    +r.pos.x = 3
    +r.pos.y = 4
    +r.ok = True
    +Put #1, 1, r
    +r.id = 2
    +Put #1, 2, r
    +Close #1
    +Print FileLen(f)
    +Open f For Random As #1 Len = 32
    +Get #1, 2, q
    +Print q.id; q.score; "["; q.nm; "]"; q.tag; q.pos.x; q.pos.y; q.ok; Loc(1)
    +Get #1, 1, q
    +Print q.id; Loc(1)
    +Close #1
    +Kill f
    +Open f For Binary As #2
    +Put #2, 1, r
    +Get #2, 1, q
    +Print q.id; Lof(2); Loc(2)
    +Close #2
    +Kill f
    +On Error GoTo Bad
    +Open f For Random As #3 Len = 8
    +Put #3, 1, r
    +End
    +Bad:
    +    Print "err"; Err
    +    Close #3
    +    Kill f
    +    End
    diff --git a/src/test/basic/fixtures/fileRandomUdt.out b/src/test/basic/fixtures/fileRandomUdt.out
    new file mode 100644
    index 0000000..a13cbd4
    --- /dev/null
    +++ b/src/test/basic/fixtures/fileRandomUdt.out
    @@ -0,0 +1,5 @@
    +64 
    +2 1.25 [ab    ]hello3 4 True 2 
    +1 1 
    +2 29 29 
    +err59 
    diff --git a/src/test/basic/fixtures/fsDirOps.bas b/src/test/basic/fixtures/fsDirOps.bas
    new file mode 100644
    index 0000000..ff2185b
    --- /dev/null
    +++ b/src/test/basic/fixtures/fsDirOps.bas
    @@ -0,0 +1,40 @@
    +' CHDIR / CURDIR$ / MKDIR / RMDIR / DIR$ / KILL / NAME / FILECOPY / FILELEN / GETATTR / SETATTR
    +Dim root As String
    +Dim sub1 As String
    +Dim f As String
    +root = App.Data
    +ChDir root
    +Print "cwd ok:"; CurDir$ = root
    +sub1 = root & "/sub"
    +MkDir sub1
    +Print "dir attr:"; GetAttr(sub1) And vbDirectory
    +f = sub1 & "/a.txt"
    +Open f For Output As #1
    +Print #1, "hello"
    +Close #1
    +Print "len:"; FileLen(f)
    +FileCopy f, sub1 & "/b.txt"
    +Print "copy len:"; FileLen(sub1 & "/b.txt")
    +Name sub1 & "/b.txt" As sub1 & "/c.txt"
    +ChDir sub1
    +Dim n As Integer
    +Dim e As String
    +e = Dir$("*.txt")
    +While e <> ""
    +    n = n + 1
    +    e = Dir$
    +Wend
    +Print "matches:"; n
    +Print "first:"; Dir$("a.*")
    +Print "next:"; Dir$
    +SetAttr f, vbReadOnly
    +Print "ro:"; GetAttr(f) And vbReadOnly
    +SetAttr f, vbNormal
    +Print "normal:"; GetAttr(f) And vbReadOnly
    +Kill f
    +Kill sub1 & "/c.txt"
    +ChDir root
    +RmDir sub1
    +Print "gone:"; Dir$("sub")
    +ChDrive "C"
    +Print "end"
    diff --git a/src/test/basic/fixtures/fsDirOps.out b/src/test/basic/fixtures/fsDirOps.out
    new file mode 100644
    index 0000000..549775f
    --- /dev/null
    +++ b/src/test/basic/fixtures/fsDirOps.out
    @@ -0,0 +1,11 @@
    +cwd ok:True 
    +dir attr:16 
    +len:6 
    +copy len:6 
    +matches:2 
    +first:a.txt
    +next:
    +ro:1 
    +normal:0 
    +gone:
    +end
    diff --git a/src/test/basic/fixtures/fsErrors.bas b/src/test/basic/fixtures/fsErrors.bas
    new file mode 100644
    index 0000000..30afac4
    --- /dev/null
    +++ b/src/test/basic/fixtures/fsErrors.bas
    @@ -0,0 +1,18 @@
    +' File system error numbers
    +On Error GoTo Handler
    +Dim root As String
    +root = App.Data
    +Kill root & "/nope.txt"
    +Print "after kill"
    +Open root & "/nope.txt" For Input As #1
    +Print "after open"
    +RmDir root & "/nodir"
    +Print "after rmdir"
    +ChDir root & "/nodir"
    +Print "after chdir"
    +Print #3, "x"
    +Print "after print#"
    +End
    +Handler:
    +    Print "err"; Err
    +    Resume Next
    diff --git a/src/test/basic/fixtures/fsErrors.out b/src/test/basic/fixtures/fsErrors.out
    new file mode 100644
    index 0000000..aed432b
    --- /dev/null
    +++ b/src/test/basic/fixtures/fsErrors.out
    @@ -0,0 +1,10 @@
    +err53 
    +after kill
    +err53 
    +after open
    +err75 
    +after rmdir
    +err76 
    +after chdir
    +err52 
    +after print#
    diff --git a/src/test/basic/fixtures/gosub.bas b/src/test/basic/fixtures/gosub.bas
    new file mode 100644
    index 0000000..e15e929
    --- /dev/null
    +++ b/src/test/basic/fixtures/gosub.bas
    @@ -0,0 +1,34 @@
    +Dim total As Integer
    +Dim i As Integer
    +GoSub Init
    +For i = 1 To 3
    +    GoSub AddIt
    +Next i
    +Print "total"; total
    +On 2 GoSub One, Two, Three
    +On 5 GoSub One, Two, Three
    +Print "fell through"
    +On 1 GoTo L1, L2
    +Print "skipped"
    +L1:
    +Print "at L1"
    +GoTo Finish
    +L2:
    +Print "at L2"
    +Finish:
    +End
    +Init:
    +    total = 100
    +    Return
    +AddIt:
    +    total = total + i
    +    Return
    +One:
    +    Print "one"
    +    Return
    +Two:
    +    Print "two"
    +    Return
    +Three:
    +    Print "three"
    +    Return
    diff --git a/src/test/basic/fixtures/gosub.out b/src/test/basic/fixtures/gosub.out
    new file mode 100644
    index 0000000..632f25f
    --- /dev/null
    +++ b/src/test/basic/fixtures/gosub.out
    @@ -0,0 +1,4 @@
    +total106 
    +two
    +fell through
    +at L1
    diff --git a/src/test/basic/fixtures/hexOct.bas b/src/test/basic/fixtures/hexOct.bas
    new file mode 100644
    index 0000000..fa3a4d2
    --- /dev/null
    +++ b/src/test/basic/fixtures/hexOct.bas
    @@ -0,0 +1,8 @@
    +Print Hex$(255); Hex$(0); Hex$(4096); Hex$(65535); Hex$(-1)
    +Print Oct$(8); Oct$(0); Oct$(64); Oct$(511)
    +Print Hex$(65536); Hex$(2147483647)
    +Print Val("&HFF"); Val("&H10"); Val("&HFFFF"); Val("&h7fffffff")
    +Print Val("&O17"); Val("&O777")
    +Print Val("12abc"); Val("  3.5e2"); Val("-7"); Val("abc"); Val("")
    +Print Val("1,000"); Val(".5"); Val("+9")
    +Print &HFF; &H7FFF; &O10
    diff --git a/src/test/basic/fixtures/hexOct.out b/src/test/basic/fixtures/hexOct.out
    new file mode 100644
    index 0000000..b44a9c1
    --- /dev/null
    +++ b/src/test/basic/fixtures/hexOct.out
    @@ -0,0 +1,8 @@
    +FF01000FFFFFFFFFFFF
    +100100777
    +100007FFFFFFF
    +255 16 65535 2147483647 
    +15 511 
    +12 350 -7 0 0 
    +1 0.5 9 
    +255 32767 8 
    diff --git a/src/test/basic/fixtures/iniReadWrite.bas b/src/test/basic/fixtures/iniReadWrite.bas
    new file mode 100644
    index 0000000..9538a0b
    --- /dev/null
    +++ b/src/test/basic/fixtures/iniReadWrite.bas
    @@ -0,0 +1,13 @@
    +Dim ini As String
    +ini = App.Data & "/test.ini"
    +Print "[" & IniRead(ini, "S", "K", "def") & "]"
    +IniWrite ini, "S", "K", "value one"
    +IniWrite ini, "S", "N", "42"
    +IniWrite ini, "T", "K", "other"
    +Print "[" & IniRead(ini, "S", "K", "def") & "]"
    +Print "[" & IniRead(ini, "T", "K", "def") & "]"
    +Print Val(IniRead(ini, "S", "N", "0")) * 2
    +IniWrite ini, "S", "K", "changed"
    +Print "[" & IniRead(ini, "S", "K", "def") & "]"
    +Print "[" & IniRead(ini, "S", "Missing", "dflt") & "]"
    +Print "[" & IniRead(ini, "Nope", "K", "nosec") & "]"
    diff --git a/src/test/basic/fixtures/iniReadWrite.out b/src/test/basic/fixtures/iniReadWrite.out
    new file mode 100644
    index 0000000..18dd652
    --- /dev/null
    +++ b/src/test/basic/fixtures/iniReadWrite.out
    @@ -0,0 +1,7 @@
    +[def]
    +[value one]
    +[other]
    +84 
    +[changed]
    +[dflt]
    +[nosec]
    diff --git a/src/test/basic/fixtures/inputStdin.bas b/src/test/basic/fixtures/inputStdin.bas
    new file mode 100644
    index 0000000..747a471
    --- /dev/null
    +++ b/src/test/basic/fixtures/inputStdin.bas
    @@ -0,0 +1,9 @@
    +Dim who As String
    +Dim age As Integer
    +Dim x As Double
    +Input "Name"; who
    +Input "Age"; age
    +Input x
    +Print "Hello, " & who & " age"; age; "x"; x * 2
    +Input who
    +Print "[" & who & "]"
    diff --git a/src/test/basic/fixtures/inputStdin.in b/src/test/basic/fixtures/inputStdin.in
    new file mode 100644
    index 0000000..2b0c23c
    --- /dev/null
    +++ b/src/test/basic/fixtures/inputStdin.in
    @@ -0,0 +1,3 @@
    +Scott
    +42
    +1.25
    diff --git a/src/test/basic/fixtures/inputStdin.out b/src/test/basic/fixtures/inputStdin.out
    new file mode 100644
    index 0000000..9456f3a
    --- /dev/null
    +++ b/src/test/basic/fixtures/inputStdin.out
    @@ -0,0 +1,2 @@
    +Hello, Scott age42 x2.5 
    +[]
    diff --git a/src/test/basic/fixtures/intOverflow.bas b/src/test/basic/fixtures/intOverflow.bas
    new file mode 100644
    index 0000000..5bed4f1
    --- /dev/null
    +++ b/src/test/basic/fixtures/intOverflow.bas
    @@ -0,0 +1,5 @@
    +Dim i As Integer
    +Dim j As Integer
    +i = 30000
    +j = i + 10000
    +Print "not reached"; j
    diff --git a/src/test/basic/fixtures/intOverflow.exit b/src/test/basic/fixtures/intOverflow.exit
    new file mode 100644
    index 0000000..1e8b314
    --- /dev/null
    +++ b/src/test/basic/fixtures/intOverflow.exit
    @@ -0,0 +1 @@
    +6
    diff --git a/src/test/basic/fixtures/intOverflow.out b/src/test/basic/fixtures/intOverflow.out
    new file mode 100644
    index 0000000..e69de29
    diff --git a/src/test/basic/fixtures/lineContinuation.bas b/src/test/basic/fixtures/lineContinuation.bas
    new file mode 100644
    index 0000000..6d8d7b0
    --- /dev/null
    +++ b/src/test/basic/fixtures/lineContinuation.bas
    @@ -0,0 +1,11 @@
    +Dim total As Integer
    +total = 1 + _
    +        2 + _
    +        3
    +Print total
    +Print "a"; _
    +      "b"
    +Print "done" ' trailing comment
    +Rem old-style comment
    +Print "x": Print "y"
    +If total = 6 Then Print "six": Print "again" Else Print "no"
    diff --git a/src/test/basic/fixtures/lineContinuation.out b/src/test/basic/fixtures/lineContinuation.out
    new file mode 100644
    index 0000000..f11f737
    --- /dev/null
    +++ b/src/test/basic/fixtures/lineContinuation.out
    @@ -0,0 +1,7 @@
    +6 
    +ab
    +done
    +x
    +y
    +six
    +again
    diff --git a/src/test/basic/fixtures/loopsMisc.bas b/src/test/basic/fixtures/loopsMisc.bas
    new file mode 100644
    index 0000000..77314e8
    --- /dev/null
    +++ b/src/test/basic/fixtures/loopsMisc.bas
    @@ -0,0 +1,38 @@
    +Dim i As Integer
    +Dim n As Integer
    +For i = 10 To 1 Step -3
    +    Print i;
    +Next i
    +Print
    +For i = 1 To 0
    +    Print "never"
    +Next i
    +i = 0
    +Do
    +    i = i + 1
    +    If i = 2 Then GoTo Skip
    +    Print "do"; i
    +Skip:
    +Loop Until i >= 4
    +i = 0
    +Do While i < 3
    +    i = i + 1
    +    If i = 2 Then Exit Do
    +Loop
    +Print "exit at"; i
    +While n < 5
    +    n = n + 2
    +Wend
    +Print n
    +For i = 1 To 3
    +    For n = 1 To 3
    +        If n = 2 Then Exit For
    +        Print i * 10 + n;
    +    Next n
    +Next i
    +Print
    +Dim d As Double
    +For d = 0 To 1 Step 0.25
    +    Print d;
    +Next d
    +Print
    diff --git a/src/test/basic/fixtures/loopsMisc.out b/src/test/basic/fixtures/loopsMisc.out
    new file mode 100644
    index 0000000..dd5b3b2
    --- /dev/null
    +++ b/src/test/basic/fixtures/loopsMisc.out
    @@ -0,0 +1,8 @@
    +10 7 4 1 
    +do1 
    +do3 
    +do4 
    +exit at2 
    +6 
    +11 21 31 
    +0 0.25 0.5 0.75 1 
    diff --git a/src/test/basic/fixtures/mathFuncs.bas b/src/test/basic/fixtures/mathFuncs.bas
    new file mode 100644
    index 0000000..ce9711a
    --- /dev/null
    +++ b/src/test/basic/fixtures/mathFuncs.bas
    @@ -0,0 +1,18 @@
    +Print Abs(-3); Sgn(-2); Sgn(0); Sgn(9); Sqr(16); Int(Exp(0))
    +Print Int(Sin(0)); Int(Cos(0)); Int(Log(1)); Int(Atn(0)); Int(Tan(0))
    +Print 7 Mod 3; -7 Mod 3; 7 \ 2; -7 \ 2; 2 ^ 10; 2 ^ 0.5
    +Print 1 / 4; 10 / 4; 1E3; 1.5E-3
    +Print 5 And 3; 5 Or 3; 5 Xor 3; Not 5; Not True; Not False
    +Print True; False; (1 = 1); (1 = 2); 1 = 1 And 2 = 2
    +Print 3 > 2 Eqv 1 > 0; 1 > 0 Imp 0 > 1
    +Print RGB(255, 128, 0); GetRed(RGB(1, 2, 3)); GetGreen(RGB(1, 2, 3)); GetBlue(RGB(1, 2, 3))
    +Randomize 1
    +Dim r As Double
    +r = Rnd
    +Print r >= 0 And r < 1
    +Print 1000000 * 1000000
    +Print 0.1 + 0.2
    +Print 1 / 3
    +Print 12345678.9
    +Print 1E20; 1E-5
    +Print 2147483647 + 1
    diff --git a/src/test/basic/fixtures/mathFuncs.out b/src/test/basic/fixtures/mathFuncs.out
    new file mode 100644
    index 0000000..c6642e9
    --- /dev/null
    +++ b/src/test/basic/fixtures/mathFuncs.out
    @@ -0,0 +1,15 @@
    +3 -1 0 1 4 1 
    +0 1 0 0 0 
    +1 -1 3 -3 1024 1.4142135623731 
    +0.25 2.5 1000 0.0015 
    +1 7 6 -6 0 -1 
    +True False True False -1 
    +-1 0 
    +16744448 1 2 3 
    +-1 
    +1000000000000 
    +0.3 
    +0.333333333333333 
    +12345678.9 
    +1E+20 1E-05 
    +2147483648 
    diff --git a/src/test/basic/fixtures/noFormRuntime.bas b/src/test/basic/fixtures/noFormRuntime.bas
    new file mode 100644
    index 0000000..177b492
    --- /dev/null
    +++ b/src/test/basic/fixtures/noFormRuntime.bas
    @@ -0,0 +1,13 @@
    +' Form and control statements need the form runtime; without one they
    +' raise error 1000 (trappable) instead of silently doing nothing.
    +On Error GoTo Handler
    +Dim f As Long
    +Set f = CreateForm("F", 10, 10)
    +Print "after createform"
    +On Error GoTo 0
    +MsgBox "unreachable dialog"
    +Print "not reached"
    +End
    +Handler:
    +    Print "err"; Err
    +    Resume Next
    diff --git a/src/test/basic/fixtures/noFormRuntime.exit b/src/test/basic/fixtures/noFormRuntime.exit
    new file mode 100644
    index 0000000..cb1a40d
    --- /dev/null
    +++ b/src/test/basic/fixtures/noFormRuntime.exit
    @@ -0,0 +1 @@
    +250
    diff --git a/src/test/basic/fixtures/noFormRuntime.out b/src/test/basic/fixtures/noFormRuntime.out
    new file mode 100644
    index 0000000..5ea84f4
    --- /dev/null
    +++ b/src/test/basic/fixtures/noFormRuntime.out
    @@ -0,0 +1,2 @@
    +err1000 
    +after createform
    diff --git a/src/test/basic/fixtures/onError.bas b/src/test/basic/fixtures/onError.bas
    new file mode 100644
    index 0000000..5c50281
    --- /dev/null
    +++ b/src/test/basic/fixtures/onError.bas
    @@ -0,0 +1,28 @@
    +Dim a As Integer
    +Dim b As Integer
    +Dim tries As Integer
    +On Error GoTo Handler
    +Print "start"
    +b = 0
    +a = 10 / b
    +Print "after div"; a
    +Print "err now:"; Err
    +Error 5
    +Print "after error 5"
    +tries = 0
    +b = 0
    +a = 100 \ b
    +Print "resumed a:"; a
    +On Error GoTo 0
    +Print "handler off"
    +Resume
    +Print "not reached"
    +End
    +Handler:
    +    Print "caught"; Err
    +    If Err = 11 And tries = 0 And b = 0 Then
    +        tries = tries + 1
    +        b = 5
    +        Resume
    +    End If
    +    Resume Next
    diff --git a/src/test/basic/fixtures/onError.exit b/src/test/basic/fixtures/onError.exit
    new file mode 100644
    index 0000000..209e3ef
    --- /dev/null
    +++ b/src/test/basic/fixtures/onError.exit
    @@ -0,0 +1 @@
    +20
    diff --git a/src/test/basic/fixtures/onError.out b/src/test/basic/fixtures/onError.out
    new file mode 100644
    index 0000000..11bb7f4
    --- /dev/null
    +++ b/src/test/basic/fixtures/onError.out
    @@ -0,0 +1,9 @@
    +start
    +caught11 
    +after div2 
    +err now:0 
    +caught5 
    +after error 5
    +caught11 
    +resumed a:20 
    +handler off
    diff --git a/src/test/basic/fixtures/onErrorSub.bas b/src/test/basic/fixtures/onErrorSub.bas
    new file mode 100644
    index 0000000..97b4fc5
    --- /dev/null
    +++ b/src/test/basic/fixtures/onErrorSub.bas
    @@ -0,0 +1,31 @@
    +Sub Risky(n As Integer)
    +    On Error GoTo Local
    +    Dim x As Integer
    +    x = 10 / n
    +    Print "ok"; x
    +    Exit Sub
    +Local:
    +    Print "sub caught"; Err
    +    Resume Next
    +End Sub
    +
    +Function Safe(n As Integer) As Integer
    +    On Error GoTo Fail
    +    Safe = 100 \ n
    +    Exit Function
    +Fail:
    +    Safe = -1
    +    Resume Next
    +End Function
    +
    +On Error GoTo Outer
    +Risky 2
    +Risky 0
    +Print Safe(4); Safe(0)
    +Dim z As Integer
    +z = 1 \ 0
    +Print "module continues"; Err
    +End
    +Outer:
    +    Print "outer caught"; Err
    +    Resume Next
    diff --git a/src/test/basic/fixtures/onErrorSub.out b/src/test/basic/fixtures/onErrorSub.out
    new file mode 100644
    index 0000000..97a65df
    --- /dev/null
    +++ b/src/test/basic/fixtures/onErrorSub.out
    @@ -0,0 +1,6 @@
    +ok5 
    +sub caught11 
    +ok0 
    +25 -1 
    +outer caught11 
    +module continues0 
    diff --git a/src/test/basic/fixtures/optionBase.bas b/src/test/basic/fixtures/optionBase.bas
    new file mode 100644
    index 0000000..cf254af
    --- /dev/null
    +++ b/src/test/basic/fixtures/optionBase.bas
    @@ -0,0 +1,13 @@
    +Option Base 1
    +Dim a(3) As Integer
    +Dim m(2, 2) As Integer
    +a(1) = 1
    +a(3) = 3
    +m(2, 2) = 22
    +Print LBound(a); UBound(a); LBound(m, 2); a(1) + a(3); m(2, 2)
    +On Error GoTo Bad
    +a(0) = 5
    +Print "not here"
    +End
    +Bad:
    +    Print "err"; Err
    diff --git a/src/test/basic/fixtures/optionBase.out b/src/test/basic/fixtures/optionBase.out
    new file mode 100644
    index 0000000..d553706
    --- /dev/null
    +++ b/src/test/basic/fixtures/optionBase.out
    @@ -0,0 +1,2 @@
    +1 3 1 4 22 
    +err9 
    diff --git a/src/test/basic/fixtures/parserExt.bas b/src/test/basic/fixtures/parserExt.bas
    new file mode 100644
    index 0000000..c0340e2
    --- /dev/null
    +++ b/src/test/basic/fixtures/parserExt.bas
    @@ -0,0 +1,57 @@
    +' Nested TYPE fields on array elements, array parameters, SWAP of
    +' array elements, the BYREF keyword and DIM name() dynamic arrays.
    +Type PointT
    +    x As Integer
    +    label As String * 3
    +End Type
    +Type ShapeT
    +    origin As PointT
    +    sides As Integer
    +End Type
    +Dim shapes(2) As ShapeT
    +shapes(1).origin.x = 5
    +shapes(1).origin.label = "abcdef"
    +shapes(1).sides = 4
    +Print shapes(1).origin.x; shapes(1).sides; shapes(1).origin.label; Len(shapes(0).origin.label)
    +
    +Sub Fill(v() As Integer, ByRef count As Integer, ByVal bump As Integer)
    +    ReDim v(9) As Integer
    +    v(9) = 7 + bump
    +    count = count + 1
    +End Sub
    +
    +Function Total(v() As Integer) As Integer
    +    Dim i As Integer
    +    Dim t As Integer
    +    For i = LBound(v) To UBound(v)
    +        t = t + v(i)
    +    Next i
    +    Total = t
    +End Function
    +
    +Dim a(3) As Integer
    +Dim n As Integer
    +Fill a(), n, 1
    +Print a(9); UBound(a); Total(a()); n
    +
    +Dim s(2) As String
    +s(0) = "first"
    +s(2) = "last"
    +Swap s(0), s(2)
    +Print s(0); " "; s(2)
    +Dim k As Integer
    +k = 1
    +Swap k, a(9)
    +Print k; a(9)
    +
    +Dim d() As String
    +ReDim d(1)
    +d(1) = "dyn"
    +Print d(1); UBound(d)
    +Erase d
    +On Error GoTo Bad
    +Print d(1)
    +End
    +Bad:
    +    Print "err"; Err
    +    End
    diff --git a/src/test/basic/fixtures/parserExt.out b/src/test/basic/fixtures/parserExt.out
    new file mode 100644
    index 0000000..0dc258f
    --- /dev/null
    +++ b/src/test/basic/fixtures/parserExt.out
    @@ -0,0 +1,6 @@
    +5 4 abc3 
    +8 9 8 1 
    +last first
    +8 1 
    +dyn1 
    +err13 
    diff --git a/src/test/basic/fixtures/printTab.bas b/src/test/basic/fixtures/printTab.bas
    new file mode 100644
    index 0000000..ff80847
    --- /dev/null
    +++ b/src/test/basic/fixtures/printTab.bas
    @@ -0,0 +1,13 @@
    +Print "a"; Tab(5); "b"; Tab(10); "c"
    +Print "abcdefgh"; Tab(3); "x"
    +Print Tab(4); "y"
    +Print "p"; Spc(3); "q"
    +Print 1, 2, 3
    +Print "x",
    +Print "y"
    +Print "n";
    +Print "m"
    +Print
    +Print -1; 2; -3
    +Print 1.5; -2.25
    +Print "s" + "t"; "u"
    diff --git a/src/test/basic/fixtures/printTab.out b/src/test/basic/fixtures/printTab.out
    new file mode 100644
    index 0000000..6f04528
    --- /dev/null
    +++ b/src/test/basic/fixtures/printTab.out
    @@ -0,0 +1,12 @@
    +a   b    c
    +abcdefgh
    +  x
    +   y
    +p   q
    +1             2             3 
    +x             y
    +nm
    +
    +-1 2 -3 
    +1.5 -2.25 
    +stu
    diff --git a/src/test/basic/fixtures/printUsing.bas b/src/test/basic/fixtures/printUsing.bas
    new file mode 100644
    index 0000000..63ae1be
    --- /dev/null
    +++ b/src/test/basic/fixtures/printUsing.bas
    @@ -0,0 +1,17 @@
    +Print Using "###.##"; 3.14159
    +Print Using "#,###"; 1234567
    +Print Using "$$#,##0.00"; 1234567.89
    +Print Using "**#,##0.00"; 42.5
    +Print Using "###-"; -42
    +Print Using "###-"; 42
    +Print Using "##.##^^^^"; 12345.678
    +Print Using "!"; "Hello"
    +Print Using "&"; "Hello"
    +Print Using "\  \"; "Hello"
    +Print Using "\  \"; "Hi"
    +Print Using "## "; 1; 2; 3
    +Print Using "#.#"; 0.05
    +Print Using "###"; 12345
    +Print Format$(42, "00000")
    +Print Format$(1234.5, "#,##0.00")
    +Print Format$(0.5, "PERCENT")
    diff --git a/src/test/basic/fixtures/printUsing.out b/src/test/basic/fixtures/printUsing.out
    new file mode 100644
    index 0000000..f124a25
    --- /dev/null
    +++ b/src/test/basic/fixtures/printUsing.out
    @@ -0,0 +1,17 @@
    +  3.14
    +%1,234,567
    +%$1,234,567.89
    +*****42.50
    + 42-
    + 42 
    + 1.23E+04
    +H
    +Hello
    +Hell
    +Hi  
    + 1  2  3 
    +0.1
    +%12345
    +00042
    +1,234.50
    +50%
    diff --git a/src/test/basic/fixtures/printUsingLiterals.bas b/src/test/basic/fixtures/printUsingLiterals.bas
    new file mode 100644
    index 0000000..4390108
    --- /dev/null
    +++ b/src/test/basic/fixtures/printUsingLiterals.bas
    @@ -0,0 +1,21 @@
    +' PRINT USING keeps literal text between fields, floats the sign next
    +' to the digits and reuses the format when the values outnumber fields.
    +Print Using "& likes &"; "Scott"; "pizza"
    +Print Using "Total: ###.## units"; 7.5
    +Print Using "###.##"; -3.14159
    +Print Using "+###"; 42
    +Print Using "## and ##"; 1; 2; 3
    +Print Using "_#=##"; 5
    +Print Using "###"; 12345
    +Print Using "$$#,##0.00-"; -1234.5
    +Print Format$(3.14159, "###.##")
    +Print Format$(-7, "+###")
    +Print Format$(42, "00000")
    +Print Format$(-42, "00000")
    +Print Format$(5, "#,##0.00")
    +On Error GoTo Bad
    +Print Using "###"; "text"
    +End
    +Bad:
    +    Print "err"; Err
    +    End
    diff --git a/src/test/basic/fixtures/printUsingLiterals.out b/src/test/basic/fixtures/printUsingLiterals.out
    new file mode 100644
    index 0000000..e02a04a
    --- /dev/null
    +++ b/src/test/basic/fixtures/printUsingLiterals.out
    @@ -0,0 +1,14 @@
    +Scott likes pizza
    +Total:   7.50 units
    + -3.14
    + +42
    + 1 and  2 3 and 
    +#= 5
    +%12345
    + $1,234.50-
    +  3.14
    +  -7
    +00042
    +-00042
    +    5.00
    +err13 
    diff --git a/src/test/basic/fixtures/procs.bas b/src/test/basic/fixtures/procs.bas
    new file mode 100644
    index 0000000..70f95fc
    --- /dev/null
    +++ b/src/test/basic/fixtures/procs.bas
    @@ -0,0 +1,27 @@
    +Function Fact(n As Long) As Long
    +    If n <= 1 Then
    +        Fact = 1
    +    Else
    +        Fact = n * Fact(n - 1)
    +    End If
    +End Function
    +Sub Inc(v As Integer)
    +    v = v + 1
    +End Sub
    +Sub NoInc(ByVal v As Integer)
    +    v = v + 1
    +End Sub
    +Function Greet(who As String, Optional punct As String) As String
    +    If punct = "" Then punct = "."
    +    Greet = "Hi " & who & punct
    +End Function
    +Dim x As Integer
    +Print Fact(10)
    +x = 5
    +Inc x
    +NoInc x
    +Print x
    +Call Inc(x)
    +Print x
    +Print Greet("Scott"); Greet("Jane", "!")
    +Print Fact(12)
    diff --git a/src/test/basic/fixtures/procs.out b/src/test/basic/fixtures/procs.out
    new file mode 100644
    index 0000000..97e129b
    --- /dev/null
    +++ b/src/test/basic/fixtures/procs.out
    @@ -0,0 +1,5 @@
    +3628800 
    +6 
    +7 
    +Hi Scott.Hi Jane!
    +479001600 
    diff --git a/src/test/basic/fixtures/resumeNoError.bas b/src/test/basic/fixtures/resumeNoError.bas
    new file mode 100644
    index 0000000..c53c37d
    --- /dev/null
    +++ b/src/test/basic/fixtures/resumeNoError.bas
    @@ -0,0 +1,3 @@
    +Print "before"
    +Resume Next
    +Print "after"
    diff --git a/src/test/basic/fixtures/resumeNoError.exit b/src/test/basic/fixtures/resumeNoError.exit
    new file mode 100644
    index 0000000..209e3ef
    --- /dev/null
    +++ b/src/test/basic/fixtures/resumeNoError.exit
    @@ -0,0 +1 @@
    +20
    diff --git a/src/test/basic/fixtures/resumeNoError.out b/src/test/basic/fixtures/resumeNoError.out
    new file mode 100644
    index 0000000..90be1f3
    --- /dev/null
    +++ b/src/test/basic/fixtures/resumeNoError.out
    @@ -0,0 +1 @@
    +before
    diff --git a/src/test/basic/fixtures/selectCase.bas b/src/test/basic/fixtures/selectCase.bas
    new file mode 100644
    index 0000000..a68d41d
    --- /dev/null
    +++ b/src/test/basic/fixtures/selectCase.bas
    @@ -0,0 +1,39 @@
    +Dim i As Integer
    +Dim s As String
    +For i = 0 To 12 Step 3
    +    Select Case i
    +        Case 0
    +            Print i; "zero"
    +        Case 1 To 5
    +            Print i; "low"
    +        Case 6, 9
    +            Print i; "six or nine"
    +        Case Is >= 12
    +            Print i; "twelve plus"
    +        Case Else
    +            Print i; "else"
    +    End Select
    +Next i
    +For i = 1 To 3
    +    Read s
    +    Select Case s
    +        Case "apple", "pear"
    +            Print s; " fruit"
    +        Case "a" To "m"
    +            Print s; " early"
    +        Case Else
    +            Print s; " late"
    +    End Select
    +Next i
    +Select Case 2.5
    +    Case Is < 2
    +        Print "small"
    +    Case Is < 3
    +        Print "medium"
    +End Select
    +Select Case 99
    +    Case 1
    +        Print "no"
    +End Select
    +Print "empty ok"
    +Data "pear", "banana", "zebra"
    diff --git a/src/test/basic/fixtures/selectCase.out b/src/test/basic/fixtures/selectCase.out
    new file mode 100644
    index 0000000..457da7a
    --- /dev/null
    +++ b/src/test/basic/fixtures/selectCase.out
    @@ -0,0 +1,10 @@
    +0 zero
    +3 low
    +6 six or nine
    +9 six or nine
    +12 twelve plus
    +pear fruit
    +banana early
    +zebra late
    +medium
    +empty ok
    diff --git a/src/test/basic/fixtures/staticVars.bas b/src/test/basic/fixtures/staticVars.bas
    new file mode 100644
    index 0000000..63dc003
    --- /dev/null
    +++ b/src/test/basic/fixtures/staticVars.bas
    @@ -0,0 +1,16 @@
    +Sub Counter()
    +    Static n As Integer
    +    Dim local As Integer
    +    n = n + 1
    +    local = local + 1
    +    Print n; local
    +End Sub
    +Function Accum(v As Integer) As Integer
    +    Static total As Integer
    +    total = total + v
    +    Accum = total
    +End Function
    +Counter
    +Counter
    +Counter
    +Print Accum(5); Accum(10); Accum(1)
    diff --git a/src/test/basic/fixtures/staticVars.out b/src/test/basic/fixtures/staticVars.out
    new file mode 100644
    index 0000000..d112009
    --- /dev/null
    +++ b/src/test/basic/fixtures/staticVars.out
    @@ -0,0 +1,4 @@
    +1 1 
    +2 1 
    +3 1 
    +5 15 16 
    diff --git a/src/test/basic/fixtures/stepCap.args b/src/test/basic/fixtures/stepCap.args
    new file mode 100644
    index 0000000..86cf5d0
    --- /dev/null
    +++ b/src/test/basic/fixtures/stepCap.args
    @@ -0,0 +1 @@
    +--steps 50000
    diff --git a/src/test/basic/fixtures/stepCap.bas b/src/test/basic/fixtures/stepCap.bas
    new file mode 100644
    index 0000000..6e4d336
    --- /dev/null
    +++ b/src/test/basic/fixtures/stepCap.bas
    @@ -0,0 +1,4 @@
    +Dim i As Long
    +Do
    +    i = i + 1
    +Loop
    diff --git a/src/test/basic/fixtures/stepCap.exit b/src/test/basic/fixtures/stepCap.exit
    new file mode 100644
    index 0000000..63fe24a
    --- /dev/null
    +++ b/src/test/basic/fixtures/stepCap.exit
    @@ -0,0 +1 @@
    +253
    diff --git a/src/test/basic/fixtures/stepCap.out b/src/test/basic/fixtures/stepCap.out
    new file mode 100644
    index 0000000..e69de29
    diff --git a/src/test/basic/fixtures/stringCompare.bas b/src/test/basic/fixtures/stringCompare.bas
    new file mode 100644
    index 0000000..60bec01
    --- /dev/null
    +++ b/src/test/basic/fixtures/stringCompare.bas
    @@ -0,0 +1,7 @@
    +Print "abc" < "abd"; "abc" = "ABC"; "a" > "B"
    +Print "abc" = "abc"; "" = ""; "abc" <> "abc "
    +Dim s As String
    +s = "x"
    +Print s & s & s; Len(s & "")
    +Print "10" + "20"; "10" & 20
    +Print Instr("Hello", "l"); Instr("Hello", "L")
    diff --git a/src/test/basic/fixtures/stringCompare.out b/src/test/basic/fixtures/stringCompare.out
    new file mode 100644
    index 0000000..4416e79
    --- /dev/null
    +++ b/src/test/basic/fixtures/stringCompare.out
    @@ -0,0 +1,5 @@
    +True False True 
    +True True True 
    +xxx1 
    +10201020
    +3 0 
    diff --git a/src/test/basic/fixtures/stringFuncs.bas b/src/test/basic/fixtures/stringFuncs.bas
    new file mode 100644
    index 0000000..8838278
    --- /dev/null
    +++ b/src/test/basic/fixtures/stringFuncs.bas
    @@ -0,0 +1,26 @@
    +Dim s As String
    +Dim big As String
    +s = "Hello" & Chr$(0) & "World"
    +Print Len(s); Asc(Mid$(s, 6, 1)); Asc(Right$(s, 1))
    +Print Left$(s, 5) & "|" & Mid$(s, 7)
    +Print Instr(s, "World"); Instr(s, Chr$(0)); Instr(3, s, "l")
    +big = String$(40000, "x")
    +Print Len(big)
    +big = big & big
    +Print Len(big); Left$(big, 3); Asc(Right$(big, 1))
    +Print Len(Space$(65535))
    +Print UCase$("mixed Case"); LCase$("MIXED Case")
    +Print "[" & LTrim$("  a  ") & "][" & RTrim$("  a  ") & "][" & Trim$("  a  ") & "]"
    +Print Str$(42); Str$(-42); Len(Str$(42))
    +Print Chr$(65) & Chr$(66); Asc("z")
    +Mid$(s, 1, 2) = "JJ"
    +Print Left$(s, 5)
    +Print String$(3, 65); String$(2, "ab")
    +Print Instr("abc", ""); Instr("", "a"); Instr("aaa", "aa")
    +On Error GoTo Bad
    +Print Left$("x", -1)
    +Print "after"
    +End
    +Bad:
    +    Print "err"; Err
    +    Resume Next
    diff --git a/src/test/basic/fixtures/stringFuncs.out b/src/test/basic/fixtures/stringFuncs.out
    new file mode 100644
    index 0000000..b255d9e
    --- /dev/null
    +++ b/src/test/basic/fixtures/stringFuncs.out
    @@ -0,0 +1,15 @@
    +11 0 100 
    +Hello|World
    +7 6 3 
    +40000 
    +80000 xxx120 
    +65535 
    +MIXED CASEmixed case
    +[a  ][  a][a]
    + 42-423 
    +AB122 
    +JJllo
    +AAAaa
    +1 0 1 
    +err5 
    +after
    diff --git a/src/test/basic/fixtures/swapVars.bas b/src/test/basic/fixtures/swapVars.bas
    new file mode 100644
    index 0000000..99e9811
    --- /dev/null
    +++ b/src/test/basic/fixtures/swapVars.bas
    @@ -0,0 +1,18 @@
    +Dim a As Integer
    +Dim b As Integer
    +Dim s As String
    +Dim t As String
    +Dim x As Double
    +Dim y As Double
    +a = 1
    +b = 2
    +Swap a, b
    +Print a; b
    +s = "left"
    +t = "right"
    +Swap s, t
    +Print s; " "; t
    +x = 1.5
    +y = -2.5
    +Swap x, y
    +Print x; y
    diff --git a/src/test/basic/fixtures/swapVars.out b/src/test/basic/fixtures/swapVars.out
    new file mode 100644
    index 0000000..b2a634f
    --- /dev/null
    +++ b/src/test/basic/fixtures/swapVars.out
    @@ -0,0 +1,3 @@
    +2 1 
    +right left
    +-2.5 1.5 
    diff --git a/src/test/basic/fixtures/typedStore.bas b/src/test/basic/fixtures/typedStore.bas
    new file mode 100644
    index 0000000..521f7a2
    --- /dev/null
    +++ b/src/test/basic/fixtures/typedStore.bas
    @@ -0,0 +1,45 @@
    +Dim i As Integer
    +Dim l As Long
    +Dim s As Single
    +Dim d As Double
    +Dim str As String
    +
    +i = 3.7
    +Print i
    +i = 2.5
    +Print i
    +i = "12"
    +Print i + 1
    +l = 3.5
    +Print l
    +s = 1 / 3
    +d = s
    +Print d = s; d = 1 / 3
    +d = 1 / 3
    +s = d
    +Print s
    +str = 42
    +Print "[" & str & "]"
    +str = 1.5
    +Print "[" & str & "]"
    +l = 32767
    +l = l + 1
    +Print l
    +i = 32767
    +Print i + 1
    +On Error GoTo Bad
    +d = 1E10
    +l = d
    +Print "next"
    +i = 32768
    +Print "next"
    +i = 100000
    +Print "next"
    +l = 3000000000#
    +Print "next"
    +i = -32769
    +Print "done"
    +End
    +Bad:
    +    Print "err"; Err
    +    Resume Next
    diff --git a/src/test/basic/fixtures/typedStore.out b/src/test/basic/fixtures/typedStore.out
    new file mode 100644
    index 0000000..1e70046
    --- /dev/null
    +++ b/src/test/basic/fixtures/typedStore.out
    @@ -0,0 +1,20 @@
    +4 
    +2 
    +13 
    +4 
    +True False 
    +0.3333333 
    +[42]
    +[1.5]
    +32768 
    +32768 
    +err6 
    +next
    +err6 
    +next
    +err6 
    +next
    +err6 
    +next
    +err6 
    +done
    diff --git a/src/test/basic/fixtures/typedStoreBoolean.bas b/src/test/basic/fixtures/typedStoreBoolean.bas
    new file mode 100644
    index 0000000..6c8ddc9
    --- /dev/null
    +++ b/src/test/basic/fixtures/typedStoreBoolean.bas
    @@ -0,0 +1,19 @@
    +' A Boolean variable coerces stores to True/False; DIM without a type is
    +' Single; a variable that is never declared keeps the assigned type.
    +Dim b As Boolean
    +b = 3
    +Print b
    +b = 0
    +Print b
    +Dim flags(1) As Boolean
    +flags(1) = -5
    +Print flags(1)
    +Dim x
    +x = 1 / 3
    +Print x
    +y = 1 / 3
    +Print y
    +DefInt Z
    +Dim z
    +z = 2.6
    +Print z
    diff --git a/src/test/basic/fixtures/typedStoreBoolean.out b/src/test/basic/fixtures/typedStoreBoolean.out
    new file mode 100644
    index 0000000..1fb9c9b
    --- /dev/null
    +++ b/src/test/basic/fixtures/typedStoreBoolean.out
    @@ -0,0 +1,6 @@
    +True 
    +False 
    +True 
    +0.3333333 
    +0.333333333333333 
    +3 
    diff --git a/src/test/basic/fixtures/udtNested.bas b/src/test/basic/fixtures/udtNested.bas
    new file mode 100644
    index 0000000..5edb848
    --- /dev/null
    +++ b/src/test/basic/fixtures/udtNested.bas
    @@ -0,0 +1,41 @@
    +Type PointT
    +    x As Integer
    +    y As Integer
    +End Type
    +Type RectT
    +    tl As PointT
    +    br As PointT
    +    label As String
    +End Type
    +Type SceneT
    +    r As RectT
    +    count As Long
    +End Type
    +Dim s As SceneT
    +Dim r2 As RectT
    +s.r.tl.x = 1
    +s.r.tl.y = 2
    +s.r.br.x = 30
    +s.r.br.y = 40
    +s.r.label = "box"
    +s.count = 7
    +Print s.r.tl.x; s.r.tl.y; s.r.br.x; s.r.br.y; s.r.label; s.count
    +r2 = s.r
    +r2.tl.x = 99
    +Print s.r.tl.x; r2.tl.x; r2.label
    +Dim arr(2) As RectT
    +Dim r3 As RectT
    +r2.br.y = 55
    +r2.label = "second"
    +arr(1) = r2
    +r2.br.y = 0
    +r3 = arr(1)
    +Print r3.br.y; arr(1).label; r2.br.y
    +Sub ShowPt(p As PointT)
    +    Print "pt"; p.x; p.y
    +End Sub
    +ShowPt s.r.br
    +Function Area(r As RectT) As Long
    +    Area = (r.br.x - r.tl.x) * (r.br.y - r.tl.y)
    +End Function
    +Print Area(s.r)
    diff --git a/src/test/basic/fixtures/udtNested.out b/src/test/basic/fixtures/udtNested.out
    new file mode 100644
    index 0000000..ca2744e
    --- /dev/null
    +++ b/src/test/basic/fixtures/udtNested.out
    @@ -0,0 +1,5 @@
    +1 2 30 40 box7 
    +1 99 box
    +55 second0 
    +pt30 40 
    +1102 
    diff --git a/src/test/basic/fuzzCommon.c b/src/test/basic/fuzzCommon.c
    new file mode 100644
    index 0000000..233217d
    --- /dev/null
    +++ b/src/test/basic/fuzzCommon.c
    @@ -0,0 +1,266 @@
    +// 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.
    +
    +
    +// fuzzCommon.c -- see fuzzCommon.h
    +
    +#include "fuzzCommon.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define FUZZ_PATH_MAX 1024
    +
    +// Statements that would reach outside the process (run a command) or
    +// stall the run (SLEEP with a mutated argument).  Programs containing
    +// these keywords are never executed by the fuzzers.
    +static const char *const sUnsafeKeywords[] = { "SHELL", "SLEEP" };
    +
    +// Function prototypes (alphabetical)
    +static void discardPrint(void *ctx, const char *text, bool newline);
    +static int  seedCompare(const void *a, const void *b);
    +unsigned int sleep(unsigned int seconds);
    +int system(const char *command);
    +
    +
    +static void discardPrint(void *ctx, const char *text, bool newline) {
    +    (void)ctx;
    +    (void)text;
    +    (void)newline;
    +}
    +
    +
    +void fuzzFreeCorpus(FuzzSeedT *seeds, int32_t count) {
    +    for (int32_t i = 0; i < count; i++) {
    +        free(seeds[i].name);
    +        free(seeds[i].text);
    +    }
    +
    +    free(seeds);
    +}
    +
    +
    +// Reads every regular file in dir, sorted by name so a seed value
    +// always maps to the same mutation sequence.
    +FuzzSeedT *fuzzLoadCorpus(const char *dir, int32_t *outCount) {
    +    *outCount = 0;
    +    DIR *d = opendir(dir);
    +
    +    if (!d) {
    +        fprintf(stderr, "fuzz: cannot open corpus directory %s\n", dir);
    +        return NULL;
    +    }
    +
    +    FuzzSeedT *seeds = NULL;
    +    int32_t    count = 0;
    +    int32_t    cap   = 0;
    +    struct dirent *ent;
    +
    +    while ((ent = readdir(d)) != NULL) {
    +        if (ent->d_name[0] == '.') {
    +            continue;
    +        }
    +
    +        char path[FUZZ_PATH_MAX];
    +        snprintf(path, sizeof(path), "%s/%s", dir, ent->d_name);
    +
    +        FILE *f = fopen(path, "rb");
    +
    +        if (!f) {
    +            continue;
    +        }
    +
    +        fseek(f, 0, SEEK_END);
    +        long size = ftell(f);
    +        fseek(f, 0, SEEK_SET);
    +
    +        if (size < 0) {
    +            fclose(f);
    +            continue;
    +        }
    +
    +        char *text = (char *)malloc((size_t)size + 1);
    +
    +        if (!text) {
    +            fclose(f);
    +            continue;
    +        }
    +
    +        size_t got = fread(text, 1, (size_t)size, f);
    +        fclose(f);
    +        text[got] = '\0';
    +
    +        if (count == cap) {
    +            cap   = cap ? cap * 2 : 64;
    +            seeds = (FuzzSeedT *)realloc(seeds, (size_t)cap * sizeof(FuzzSeedT));
    +        }
    +
    +        seeds[count].name = strdup(ent->d_name);
    +        seeds[count].text = text;
    +        seeds[count].len  = (int32_t)got;
    +        count++;
    +    }
    +
    +    closedir(d);
    +
    +    if (count > 0) {
    +        qsort(seeds, (size_t)count, sizeof(FuzzSeedT), seedCompare);
    +    }
    +
    +    *outCount = count;
    +    return seeds;
    +}
    +
    +
    +// xorshift64*: cheap, deterministic, good enough for mutation choice.
    +uint32_t fuzzRngNext(FuzzRngT *rng, uint32_t bound) {
    +    uint64_t x = rng->state;
    +    x ^= x >> 12;
    +    x ^= x << 25;
    +    x ^= x >> 27;
    +    rng->state = x;
    +
    +    uint32_t v = (uint32_t)((x * 0x2545F4914F6CDD1DULL) >> 32);
    +
    +    return bound ? v % bound : v;
    +}
    +
    +
    +void fuzzRngSeed(FuzzRngT *rng, uint64_t seed) {
    +    rng->state = seed ? seed : 0x9E3779B97F4A7C15ULL;
    +}
    +
    +
    +// Load a module into a fresh VM and run it headless with a step cap.
    +// PRINT output is discarded; no input, UI, SQL or extern callbacks are
    +// installed so those opcodes take their "host absent" paths.
    +FuzzRunResultE fuzzRunModule(BasModuleT *mod, int32_t stepCap, FuzzRunStatsT *stats) {
    +    BasVmT *vm = basVmCreate();
    +
    +    if (!vm) {
    +        fprintf(stderr, "fuzz: out of memory creating VM\n");
    +        exit(2);
    +    }
    +
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, discardPrint, NULL);
    +    basVmSetStepLimit(vm, FUZZ_STEP_SLICE);
    +
    +    int32_t        total  = 0;
    +    FuzzRunResultE out    = FUZZ_RUN_HALTED;
    +    BasVmResultE   result = basVmRun(vm);
    +
    +    while (result == BAS_VM_STEP_LIMIT) {
    +        total += vm->stepCount;
    +
    +        if (total >= stepCap) {
    +            out = FUZZ_RUN_STEP_CAP;
    +            break;
    +        }
    +
    +        result = basVmRun(vm);
    +    }
    +
    +    if (out != FUZZ_RUN_STEP_CAP && result != BAS_VM_HALTED && result != BAS_VM_OK) {
    +        out = FUZZ_RUN_ERROR;
    +    }
    +
    +    basVmDestroy(vm);
    +
    +    if (stats) {
    +        if (out == FUZZ_RUN_HALTED) {
    +            stats->halted++;
    +        } else if (out == FUZZ_RUN_ERROR) {
    +            stats->errors++;
    +        } else {
    +            stats->stepCaps++;
    +        }
    +    }
    +
    +    return out;
    +}
    +
    +
    +bool fuzzSourceIsSafe(const char *text) {
    +    for (size_t i = 0; i < sizeof(sUnsafeKeywords) / sizeof(sUnsafeKeywords[0]); i++) {
    +        if (strcasestr(text, sUnsafeKeywords[i])) {
    +            return false;
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
    +bool fuzzWriteFile(const char *path, const void *data, int32_t len) {
    +    FILE *f = fopen(path, "wb");
    +
    +    if (!f) {
    +        return false;
    +    }
    +
    +    size_t wrote = fwrite(data, 1, (size_t)len, f);
    +    fclose(f);
    +    return wrote == (size_t)len;
    +}
    +
    +
    +// Rewrites an already-open "last input" file in place.  Creating and
    +// deleting a file per mutant costs more than the mutant itself on a
    +// journalling file system, so the file is opened once and reused.
    +bool fuzzWriteLast(FILE *f, const void *data, int32_t len) {
    +    if (!f) {
    +        return false;
    +    }
    +
    +    rewind(f);
    +
    +    size_t wrote = fwrite(data, 1, (size_t)len, f);
    +    fflush(f);
    +
    +    return wrote == (size_t)len && ftruncate(fileno(f), (off_t)len) == 0;
    +}
    +
    +
    +static int seedCompare(const void *a, const void *b) {
    +    return strcmp(((const FuzzSeedT *)a)->name, ((const FuzzSeedT *)b)->name);
    +}
    +
    +
    +// Corrupted bytecode can decode to OP_SLEEP with any operand or to
    +// OP_SHELL with a corrupted command string.  Both libc entry points are
    +// interposed here (a definition in the executable wins over libc's) so
    +// a mutant can neither stall the run nor execute a command.
    +unsigned int sleep(unsigned int seconds) {
    +    (void)seconds;
    +    return 0;
    +}
    +
    +
    +int system(const char *command) {
    +    (void)command;
    +    return -1;
    +}
    diff --git a/src/test/basic/fuzzCommon.h b/src/test/basic/fuzzCommon.h
    new file mode 100644
    index 0000000..a4df045
    --- /dev/null
    +++ b/src/test/basic/fuzzCommon.h
    @@ -0,0 +1,76 @@
    +// 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.
    +
    +
    +// fuzzCommon.h -- shared pieces of the BASIC fuzz harnesses
    +//
    +// A seeded xorshift RNG (runs are reproducible from the seed printed in
    +// the summary), corpus loading, the SHELL/SLEEP safety filter and a
    +// capped headless VM run used by both fuzzCompile and fuzzModule.
    +
    +#ifndef FUZZ_COMMON_H
    +#define FUZZ_COMMON_H
    +
    +#include "runtime/vm.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define FUZZ_DEFAULT_SEED       12345
    +#define FUZZ_DEFAULT_MUTATIONS  10
    +#define FUZZ_DEFAULT_STEP_CAP   200000
    +#define FUZZ_STEP_SLICE         BAS_VM_DEFAULT_STEP_SLICE
    +
    +typedef struct {
    +    uint64_t state;
    +} FuzzRngT;
    +
    +typedef struct {
    +    char    *name;   // file name (owned)
    +    char    *text;   // NUL-terminated program text (owned)
    +    int32_t  len;
    +} FuzzSeedT;
    +
    +typedef enum {
    +    FUZZ_RUN_HALTED,     // program ran to completion
    +    FUZZ_RUN_ERROR,      // runtime error (expected and fine)
    +    FUZZ_RUN_STEP_CAP    // step cap reached (loop; fine)
    +} FuzzRunResultE;
    +
    +typedef struct {
    +    int32_t halted;
    +    int32_t errors;
    +    int32_t stepCaps;
    +} FuzzRunStatsT;
    +
    +// Function prototypes (alphabetical)
    +void          fuzzFreeCorpus(FuzzSeedT *seeds, int32_t count);
    +FuzzSeedT    *fuzzLoadCorpus(const char *dir, int32_t *outCount);
    +uint32_t      fuzzRngNext(FuzzRngT *rng, uint32_t bound);
    +void          fuzzRngSeed(FuzzRngT *rng, uint64_t seed);
    +FuzzRunResultE fuzzRunModule(BasModuleT *mod, int32_t stepCap, FuzzRunStatsT *stats);
    +bool          fuzzSourceIsSafe(const char *text);
    +bool          fuzzWriteFile(const char *path, const void *data, int32_t len);
    +bool          fuzzWriteLast(FILE *f, const void *data, int32_t len);
    +
    +#endif // FUZZ_COMMON_H
    diff --git a/src/test/basic/fuzzCompile.c b/src/test/basic/fuzzCompile.c
    new file mode 100644
    index 0000000..0558718
    --- /dev/null
    +++ b/src/test/basic/fuzzCompile.c
    @@ -0,0 +1,392 @@
    +// 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.
    +
    +
    +// fuzzCompile.c -- compiler/VM fuzz harness
    +//
    +// Takes every program in a corpus directory, applies N deterministic
    +// token-level mutations to each (delete / duplicate / swap a token,
    +// truncate, insert a random keyword, flip a character) and pushes the
    +// result through the parser, code generator and VM.  Every mutant must
    +// either fail to compile or run to completion (normal end, runtime
    +// error, or the step cap) -- never a crash or sanitizer report.
    +//
    +//   fuzzCompile [-n mutations] [-seed S] [-steps N] [-save DIR] CORPUSDIR
    +//
    +// -save writes every mutant that compiles AND hits the step cap to DIR
    +// (useful when a hang is suspected).  The last mutant handed to the
    +// compiler is always written to fuzzCompile.last.bas so a crash leaves
    +// its repro behind.
    +
    +#include "fuzzCommon.h"
    +
    +#include "compiler/parser.h"
    +#include "runtime/serialize.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define FUZZ_MUTATION_KINDS   6
    +#define FUZZ_MAX_TOKENS       4096
    +#define FUZZ_MAX_PATH         1024
    +#define FUZZ_LAST_FILE        "fuzzCompile.last.bas"
    +#define FUZZ_FLIP_PRINTABLE   ('~' - ' ' + 1)
    +
    +typedef struct {
    +    int32_t start;
    +    int32_t len;
    +} TokenT;
    +
    +typedef enum {
    +    MUT_DELETE_TOKEN,
    +    MUT_DUPLICATE_TOKEN,
    +    MUT_SWAP_TOKENS,
    +    MUT_TRUNCATE,
    +    MUT_INSERT_KEYWORD,
    +    MUT_FLIP_CHAR
    +} MutationE;
    +
    +static const char *const sKeywords[] = {
    +    "PRINT", "DIM", "AS", "INTEGER", "LONG", "STRING", "DOUBLE", "FOR", "NEXT", "TO", "STEP",
    +    "IF", "THEN", "ELSE", "END", "SUB", "FUNCTION", "EXIT", "DO", "LOOP", "WHILE", "WEND",
    +    "SELECT", "CASE", "GOTO", "GOSUB", "RETURN", "ON", "ERROR", "RESUME", "OPEN", "CLOSE",
    +    "TYPE", "REDIM", "PRESERVE", "STATIC", "SWAP", "CONST", "DATA", "READ", "RESTORE", "ERASE",
    +    "(", ")", ",", ";", "=", "+", "-", "*", "/", "\\", "^", "&", "#", "$", "%", "!", ":", "\n",
    +    "\"", "1", "0", "-1", "32767", "2147483647", "1E308", "\"x\"", "a", "b", "i", "s$", "x%",
    +    "MID$", "LEFT$", "CHR$", "STR$", "VAL", "LEN", "INPUT", "GET", "PUT", "LEN=", "USING",
    +    "TAB", "SPC", "CINT", "CLNG", "HEX$", "OCT$", "SET", "ME", ".", "CreateForm", "LOAD",
    +};
    +
    +// Function prototypes (alphabetical; main last)
    +static int32_t  applyMutation(FuzzRngT *rng, const char *src, int32_t srcLen, char *out, int32_t outCap, MutationE kind);
    +static bool     compileAndRun(const char *src, int32_t srcLen, int32_t stepCap, FuzzRunStatsT *stats, int32_t *compiled);
    +static int32_t  tokenize(const char *src, int32_t srcLen, TokenT *tokens, int32_t maxTokens);
    +static void     usage(void);
    +int main(int argc, char **argv);
    +
    +
    +// Applies one mutation of the given kind and returns the mutant length.
    +// The mutant is always NUL-terminated and never longer than outCap - 1.
    +static int32_t applyMutation(FuzzRngT *rng, const char *src, int32_t srcLen, char *out, int32_t outCap, MutationE kind) {
    +    static TokenT tokens[FUZZ_MAX_TOKENS];
    +    int32_t count = tokenize(src, srcLen, tokens, FUZZ_MAX_TOKENS);
    +    int32_t pos   = 0;
    +
    +    if (count == 0 || srcLen == 0) {
    +        out[0] = '\0';
    +        return 0;
    +    }
    +
    +    int32_t a = (int32_t)fuzzRngNext(rng, (uint32_t)count);
    +    int32_t b = (int32_t)fuzzRngNext(rng, (uint32_t)count);
    +
    +    for (int32_t i = 0; i < count; i++) {
    +        const char *piece = src + tokens[i].start;
    +        int32_t     len   = tokens[i].len;
    +        bool        skip  = false;
    +
    +        switch (kind) {
    +            case MUT_DELETE_TOKEN:
    +                skip = (i == a);
    +                break;
    +
    +            case MUT_SWAP_TOKENS:
    +                if (i == a) {
    +                    piece = src + tokens[b].start;
    +                    len   = tokens[b].len;
    +                } else if (i == b) {
    +                    piece = src + tokens[a].start;
    +                    len   = tokens[a].len;
    +                }
    +                break;
    +
    +            case MUT_TRUNCATE:
    +                if (i == a) {
    +                    len = (int32_t)fuzzRngNext(rng, (uint32_t)len + 1);
    +                }
    +                break;
    +
    +            case MUT_INSERT_KEYWORD:
    +                if (i == a) {
    +                    const char *kw    = sKeywords[fuzzRngNext(rng, (uint32_t)(sizeof(sKeywords) / sizeof(sKeywords[0])))];
    +                    int32_t     kwLen = (int32_t)strlen(kw);
    +
    +                    if (pos + kwLen + 2 < outCap) {
    +                        out[pos++] = ' ';
    +                        memcpy(out + pos, kw, (size_t)kwLen);
    +                        pos += kwLen;
    +                        out[pos++] = ' ';
    +                    }
    +                }
    +                break;
    +
    +            case MUT_DUPLICATE_TOKEN:
    +            case MUT_FLIP_CHAR:
    +                break;
    +        }
    +
    +        if (skip || pos + len >= outCap) {
    +            continue;
    +        }
    +
    +        memcpy(out + pos, piece, (size_t)len);
    +        pos += len;
    +
    +        if (kind == MUT_DUPLICATE_TOKEN && i == a && pos + len < outCap) {
    +            memcpy(out + pos, piece, (size_t)len);
    +            pos += len;
    +        }
    +
    +        if (kind == MUT_TRUNCATE && i == a) {
    +            break;
    +        }
    +    }
    +
    +    if (kind == MUT_FLIP_CHAR && pos > 0) {
    +        int32_t at = (int32_t)fuzzRngNext(rng, (uint32_t)pos);
    +
    +        // Mostly printable replacements, occasionally any byte at all.
    +        if (fuzzRngNext(rng, 4) == 0) {
    +            out[at] = (char)fuzzRngNext(rng, 256);
    +        } else {
    +            out[at] = (char)(' ' + fuzzRngNext(rng, FUZZ_FLIP_PRINTABLE));
    +        }
    +    }
    +
    +    out[pos] = '\0';
    +    return pos;
    +}
    +
    +
    +// Returns false only if the harness itself could not proceed (never for
    +// a compile error or runtime error, which are the expected outcomes).
    +static bool compileAndRun(const char *src, int32_t srcLen, int32_t stepCap, FuzzRunStatsT *stats, int32_t *compiled) {
    +    BasParserT *parser = (BasParserT *)malloc(sizeof(BasParserT));
    +
    +    if (!parser) {
    +        return false;
    +    }
    +
    +    basParserInit(parser, src, srcLen);
    +
    +    if (!basParse(parser)) {
    +        basParserFree(parser);
    +        free(parser);
    +        return true;
    +    }
    +
    +    BasModuleT *mod = basParserBuildModule(parser);
    +    basParserFree(parser);
    +    free(parser);
    +
    +    if (!mod) {
    +        return true;
    +    }
    +
    +    (*compiled)++;
    +
    +    // Round-trip through the serializer too: a compiled mutant is a
    +    // free test of serialize/deserialize on odd-but-valid modules.
    +    int32_t  serLen = 0;
    +    uint8_t *ser    = basModuleSerialize(mod, &serLen);
    +
    +    if (ser) {
    +        BasModuleT *copy = basModuleDeserialize(ser, serLen);
    +
    +        if (copy) {
    +            basModuleFree(copy);
    +        }
    +
    +        free(ser);
    +    }
    +
    +    fuzzRunModule(mod, stepCap, stats);
    +    basModuleFree(mod);
    +    return true;
    +}
    +
    +
    +// Word tokens (identifiers, numbers, type suffixes), string literals,
    +// runs of whitespace and single punctuation characters.
    +static int32_t tokenize(const char *src, int32_t srcLen, TokenT *tokens, int32_t maxTokens) {
    +    int32_t count = 0;
    +    int32_t i     = 0;
    +
    +    while (i < srcLen && count < maxTokens) {
    +        int32_t start = i;
    +        char    c     = src[i];
    +
    +        if (c == '"') {
    +            i++;
    +
    +            while (i < srcLen && src[i] != '"' && src[i] != '\n') {
    +                i++;
    +            }
    +
    +            if (i < srcLen && src[i] == '"') {
    +                i++;
    +            }
    +        } else if (c == ' ' || c == '\t') {
    +            while (i < srcLen && (src[i] == ' ' || src[i] == '\t')) {
    +                i++;
    +            }
    +        } else if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || c == '.') {
    +            while (i < srcLen) {
    +                char d = src[i];
    +                bool word = (d >= '0' && d <= '9') || (d >= 'A' && d <= 'Z') || (d >= 'a' && d <= 'z') || d == '_' || d == '.' || d == '$' || d == '%' || d == '&' || d == '!' || d == '#';
    +
    +                if (!word) {
    +                    break;
    +                }
    +
    +                i++;
    +            }
    +        } else {
    +            i++;
    +        }
    +
    +        tokens[count].start = start;
    +        tokens[count].len   = i - start;
    +        count++;
    +    }
    +
    +    return count;
    +}
    +
    +
    +static void usage(void) {
    +    fprintf(stderr, "Usage: fuzzCompile [-n mutations] [-seed S] [-steps N] [-save DIR] CORPUSDIR\n");
    +}
    +
    +
    +int main(int argc, char **argv) {
    +    int32_t     mutations = FUZZ_DEFAULT_MUTATIONS;
    +    uint64_t    seed      = FUZZ_DEFAULT_SEED;
    +    int32_t     stepCap   = FUZZ_DEFAULT_STEP_CAP;
    +    const char *saveDir   = NULL;
    +    const char *corpusDir = NULL;
    +
    +    for (int32_t i = 1; i < argc; i++) {
    +        if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) {
    +            mutations = (int32_t)strtol(argv[++i], NULL, 10);
    +        } else if (strcmp(argv[i], "-seed") == 0 && i + 1 < argc) {
    +            seed = strtoull(argv[++i], NULL, 10);
    +        } else if (strcmp(argv[i], "-steps") == 0 && i + 1 < argc) {
    +            stepCap = (int32_t)strtol(argv[++i], NULL, 10);
    +        } else if (strcmp(argv[i], "-save") == 0 && i + 1 < argc) {
    +            saveDir = argv[++i];
    +        } else if (argv[i][0] != '-') {
    +            corpusDir = argv[i];
    +        } else {
    +            usage();
    +            return 2;
    +        }
    +    }
    +
    +    if (!corpusDir) {
    +        usage();
    +        return 2;
    +    }
    +
    +    int32_t    seedCount = 0;
    +    FuzzSeedT *seeds     = fuzzLoadCorpus(corpusDir, &seedCount);
    +
    +    if (!seeds || seedCount == 0) {
    +        fprintf(stderr, "fuzzCompile: empty corpus\n");
    +        return 2;
    +    }
    +
    +    FuzzRngT rng;
    +    fuzzRngSeed(&rng, seed);
    +
    +    FILE         *last     = fopen(FUZZ_LAST_FILE, "wb");
    +    FuzzRunStatsT stats    = { 0, 0, 0 };
    +    int32_t       mutants  = 0;
    +    int32_t       compiled = 0;
    +    int32_t       skipped  = 0;
    +    int32_t       outCap   = 0;
    +    char         *out      = NULL;
    +
    +    printf("DVX BASIC Compiler Fuzz\n");
    +    printf("=======================\n");
    +    printf("corpus: %d programs, %d mutations each, seed %llu, step cap %d\n", (int)seedCount, (int)mutations, (unsigned long long)seed, (int)stepCap);
    +
    +    for (int32_t s = 0; s < seedCount; s++) {
    +        if (!fuzzSourceIsSafe(seeds[s].text)) {
    +            skipped++;
    +            continue;
    +        }
    +
    +        // Room for every token duplicated plus an inserted keyword.
    +        int32_t need = seeds[s].len * 2 + FUZZ_MAX_PATH;
    +
    +        if (need > outCap) {
    +            outCap = need;
    +            out    = (char *)realloc(out, (size_t)outCap);
    +
    +            if (!out) {
    +                fprintf(stderr, "fuzzCompile: out of memory\n");
    +                return 2;
    +            }
    +        }
    +
    +        // The unmutated seed first: it must compile or not exactly as the
    +        // harness that owns it expects, and it seeds the run statistics.
    +        compileAndRun(seeds[s].text, seeds[s].len, stepCap, &stats, &compiled);
    +
    +        for (int32_t m = 0; m < mutations; m++) {
    +            MutationE kind = (MutationE)fuzzRngNext(&rng, FUZZ_MUTATION_KINDS);
    +            int32_t   len  = applyMutation(&rng, seeds[s].text, seeds[s].len, out, outCap, kind);
    +
    +            if (!fuzzSourceIsSafe(out)) {
    +                skipped++;
    +                continue;
    +            }
    +
    +            fuzzWriteLast(last, out, len);
    +            mutants++;
    +
    +            int32_t before = stats.stepCaps;
    +            compileAndRun(out, len, stepCap, &stats, &compiled);
    +
    +            if (saveDir && stats.stepCaps != before) {
    +                char path[FUZZ_MAX_PATH];
    +                snprintf(path, sizeof(path), "%s/stepcap-%s-%d.bas", saveDir, seeds[s].name, (int)m);
    +                fuzzWriteFile(path, out, len);
    +            }
    +        }
    +    }
    +
    +    free(out);
    +    fuzzFreeCorpus(seeds, seedCount);
    +
    +    if (last) {
    +        fclose(last);
    +    }
    +
    +    remove(FUZZ_LAST_FILE);
    +
    +    printf("mutants: %d  compiled: %d  ran-to-end: %d  runtime-errors: %d  step-caps: %d  skipped(unsafe): %d\n", (int)mutants, (int)compiled, (int)stats.halted, (int)stats.errors, (int)stats.stepCaps, (int)skipped);
    +    printf("fuzzCompile: OK\n");
    +    return 0;
    +}
    diff --git a/src/test/basic/fuzzModule.c b/src/test/basic/fuzzModule.c
    new file mode 100644
    index 0000000..e415b7e
    --- /dev/null
    +++ b/src/test/basic/fuzzModule.c
    @@ -0,0 +1,486 @@
    +// 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.
    +
    +
    +// fuzzModule.c -- bytecode / deserializer fuzz harness
    +//
    +// Every corpus program is compiled and serialized (module + debug
    +// blob); every .app given on the command line contributes its MODULE
    +// and DEBUG resources.  Each blob is then mutated N times (byte flip,
    +// bit flip, truncation, chunk deletion / duplication, 32-bit
    +// length-field corruption) and fed to basModuleDeserialize; a mutant
    +// that still deserializes has its DEBUG blob (also mutated) attached
    +// and is then loaded and run with a step cap.  Nothing here may crash
    +// or trip a sanitizer: corrupt input must be rejected or execute as a
    +// clean runtime error.
    +//
    +//   fuzzModule [-n mutations] [-seed S] [-steps N] [-corpus DIR] [file.app ...]
    +//
    +// The blob currently under test is written to fuzzModule.last.bin
    +// before deserialization so a crash leaves its repro behind.
    +
    +#include "fuzzCommon.h"
    +
    +#include "basRes.h"
    +#include "compiler/parser.h"
    +#include "dvxRes.h"
    +#include "runtime/serialize.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define FUZZ_MUTATION_KINDS  6
    +#define FUZZ_LAST_FILE       "fuzzModule.last.bin"
    +#define FUZZ_LAST_DBG_FILE   "fuzzModule.last.dbg"
    +#define FUZZ_LEAK_FILE       "fuzzModule.leak-%d.bin" // -leakcheck: the mutants preceding the first detected leak
    +#define FUZZ_LEAK_HISTORY    4                       // LeakSanitizer can lag a few mutants behind (stale stack pointers)
    +#define FUZZ_CHUNK_MAX       64
    +#define FUZZ_FIELD_VALUES    8
    +
    +typedef enum {
    +    MUT_BYTE,
    +    MUT_BIT,
    +    MUT_TRUNCATE,
    +    MUT_DELETE_CHUNK,
    +    MUT_DUPLICATE_CHUNK,
    +    MUT_FIELD
    +} MutationE;
    +
    +typedef struct {
    +    uint8_t *mod;
    +    int32_t  modLen;
    +    uint8_t *dbg;      // may be NULL
    +    int32_t  dbgLen;
    +    char     name[256];
    +} BlobT;
    +
    +typedef struct {
    +    int32_t rejected;
    +    int32_t accepted;
    +} DeserStatsT;
    +
    +// Interesting 32-bit values for length-field corruption.
    +static const int32_t sFieldValues[FUZZ_FIELD_VALUES] = { 0, -1, 1, 0x7FFFFFFF, (int32_t)0x80000000, 0x10000, 255, 65536 * 4 };
    +
    +// "Last input" repro files, opened once and rewritten per mutant.
    +static FILE *sLastMod = NULL;
    +static FILE *sLastDbg = NULL;
    +
    +// Ring of the most recent mutants for -leakcheck.
    +typedef struct {
    +    uint8_t *mod;
    +    int32_t  modLen;
    +    char     name[256];
    +    int32_t  mutation;
    +} LeakHistT;
    +
    +static LeakHistT sLeakHist[FUZZ_LEAK_HISTORY];
    +static int32_t   sLeakHistNext = 0;
    +
    +// LeakSanitizer's mid-run check; resolves to NULL when the binary is not
    +// built with -fsanitize=address, so -leakcheck degrades to a no-op there.
    +extern int __lsan_do_recoverable_leak_check(void) __attribute__((weak));
    +
    +// Function prototypes (alphabetical; main last)
    +static void    addAppBlob(BlobT **blobs, int32_t *count, const char *path);
    +static void    addCorpusBlobs(BlobT **blobs, int32_t *count, const char *dir);
    +static void    blobAppend(BlobT **blobs, int32_t *count, const BlobT *b);
    +static bool    leaked(void);
    +static void    leakHistAdd(const uint8_t *mod, int32_t modLen, const char *name, int32_t mutation);
    +static void    leakHistDump(void);
    +static int32_t mutate(FuzzRngT *rng, const uint8_t *src, int32_t srcLen, uint8_t *out, MutationE kind);
    +static void    testBlob(const uint8_t *mod, int32_t modLen, const uint8_t *dbg, int32_t dbgLen, int32_t stepCap, DeserStatsT *deser, FuzzRunStatsT *run);
    +static void    usage(void);
    +int main(int argc, char **argv);
    +
    +
    +static void addAppBlob(BlobT **blobs, int32_t *count, const char *path) {
    +    DvxResHandleT *res = dvxResOpen(path);
    +
    +    if (!res) {
    +        fprintf(stderr, "fuzzModule: %s: no resource directory (skipped)\n", path);
    +        return;
    +    }
    +
    +    BlobT    b;
    +    uint32_t modSize = 0;
    +    uint32_t dbgSize = 0;
    +
    +    memset(&b, 0, sizeof(b));
    +    b.mod = (uint8_t *)dvxResRead(res, BAS_RES_MODULE, &modSize);
    +    b.dbg = (uint8_t *)dvxResRead(res, BAS_RES_DEBUG, &dbgSize);
    +    dvxResClose(res);
    +
    +    if (!b.mod) {
    +        fprintf(stderr, "fuzzModule: %s: no MODULE resource (skipped)\n", path);
    +        free(b.dbg);
    +        return;
    +    }
    +
    +    b.modLen = (int32_t)modSize;
    +    b.dbgLen = (int32_t)dbgSize;
    +    snprintf(b.name, sizeof(b.name), "%s", path);
    +    blobAppend(blobs, count, &b);
    +}
    +
    +
    +static void addCorpusBlobs(BlobT **blobs, int32_t *count, const char *dir) {
    +    int32_t    seedCount = 0;
    +    FuzzSeedT *seeds     = fuzzLoadCorpus(dir, &seedCount);
    +
    +    for (int32_t i = 0; i < seedCount; i++) {
    +        if (!fuzzSourceIsSafe(seeds[i].text)) {
    +            continue;
    +        }
    +
    +        BasParserT *parser = (BasParserT *)malloc(sizeof(BasParserT));
    +
    +        if (!parser) {
    +            break;
    +        }
    +
    +        basParserInit(parser, seeds[i].text, seeds[i].len);
    +
    +        if (!basParse(parser)) {
    +            basParserFree(parser);
    +            free(parser);
    +            continue;
    +        }
    +
    +        BasModuleT *mod = basParserBuildModule(parser);
    +        basParserFree(parser);
    +        free(parser);
    +
    +        if (!mod) {
    +            continue;
    +        }
    +
    +        BlobT b;
    +        memset(&b, 0, sizeof(b));
    +        b.mod = basModuleSerialize(mod, &b.modLen);
    +        b.dbg = basDebugSerialize(mod, &b.dbgLen);
    +        basModuleFree(mod);
    +
    +        if (!b.mod) {
    +            free(b.dbg);
    +            continue;
    +        }
    +
    +        snprintf(b.name, sizeof(b.name), "%s", seeds[i].name);
    +        blobAppend(blobs, count, &b);
    +    }
    +
    +    fuzzFreeCorpus(seeds, seedCount);
    +}
    +
    +
    +static void blobAppend(BlobT **blobs, int32_t *count, const BlobT *b) {
    +    BlobT *grown = (BlobT *)realloc(*blobs, (size_t)(*count + 1) * sizeof(BlobT));
    +
    +    if (!grown) {
    +        fprintf(stderr, "fuzzModule: out of memory\n");
    +        exit(2);
    +    }
    +
    +    grown[*count] = *b;
    +    *blobs        = grown;
    +    (*count)++;
    +}
    +
    +
    +// True when LeakSanitizer finds unreachable allocations right now.
    +static bool leaked(void) {
    +    return __lsan_do_recoverable_leak_check && __lsan_do_recoverable_leak_check() != 0;
    +}
    +
    +
    +static void leakHistAdd(const uint8_t *mod, int32_t modLen, const char *name, int32_t mutation) {
    +    LeakHistT *h = &sLeakHist[sLeakHistNext % FUZZ_LEAK_HISTORY];
    +
    +    free(h->mod);
    +    h->mod = (uint8_t *)malloc((size_t)modLen);
    +
    +    if (h->mod) {
    +        memcpy(h->mod, mod, (size_t)modLen);
    +    }
    +
    +    h->modLen   = modLen;
    +    h->mutation = mutation;
    +    snprintf(h->name, sizeof(h->name), "%s", name);
    +    sLeakHistNext++;
    +}
    +
    +
    +// Writes the recent mutants (oldest first) as fuzzModule.leak-N.bin.
    +static void leakHistDump(void) {
    +    for (int32_t i = 0; i < FUZZ_LEAK_HISTORY; i++) {
    +        LeakHistT *h = &sLeakHist[(sLeakHistNext + i) % FUZZ_LEAK_HISTORY];
    +
    +        if (!h->mod) {
    +            continue;
    +        }
    +
    +        char path[64];
    +        snprintf(path, sizeof(path), FUZZ_LEAK_FILE, (int)i);
    +
    +        FILE *f = fopen(path, "wb");
    +
    +        if (f) {
    +            fuzzWriteLast(f, h->mod, h->modLen);
    +            fclose(f);
    +        }
    +
    +        printf("      %s: %s mutation %d\n", path, h->name, (int)h->mutation);
    +    }
    +}
    +
    +
    +// out must hold srcLen + FUZZ_CHUNK_MAX bytes.  Returns the mutant length.
    +static int32_t mutate(FuzzRngT *rng, const uint8_t *src, int32_t srcLen, uint8_t *out, MutationE kind) {
    +    memcpy(out, src, (size_t)srcLen);
    +    int32_t len = srcLen;
    +
    +    if (len == 0) {
    +        return 0;
    +    }
    +
    +    int32_t at = (int32_t)fuzzRngNext(rng, (uint32_t)len);
    +
    +    switch (kind) {
    +        case MUT_BYTE:
    +            out[at] = (uint8_t)fuzzRngNext(rng, 256);
    +            break;
    +
    +        case MUT_BIT:
    +            out[at] ^= (uint8_t)(1u << fuzzRngNext(rng, 8));
    +            break;
    +
    +        case MUT_TRUNCATE:
    +            len = at;
    +            break;
    +
    +        case MUT_DELETE_CHUNK: {
    +            int32_t n = 1 + (int32_t)fuzzRngNext(rng, FUZZ_CHUNK_MAX);
    +
    +            if (at + n > len) {
    +                n = len - at;
    +            }
    +
    +            memmove(out + at, out + at + n, (size_t)(len - at - n));
    +            len -= n;
    +            break;
    +        }
    +
    +        case MUT_DUPLICATE_CHUNK: {
    +            int32_t n = 1 + (int32_t)fuzzRngNext(rng, FUZZ_CHUNK_MAX);
    +
    +            if (at + n > len) {
    +                n = len - at;
    +            }
    +
    +            memmove(out + at + n, out + at, (size_t)(len - at));
    +            len += n;
    +            break;
    +        }
    +
    +        case MUT_FIELD: {
    +            if (len >= 4) {
    +                int32_t fieldAt = (int32_t)fuzzRngNext(rng, (uint32_t)(len - 3));
    +                int32_t value   = sFieldValues[fuzzRngNext(rng, FUZZ_FIELD_VALUES)];
    +                memcpy(out + fieldAt, &value, sizeof(value));
    +            }
    +            break;
    +        }
    +    }
    +
    +    return len;
    +}
    +
    +
    +static void testBlob(const uint8_t *mod, int32_t modLen, const uint8_t *dbg, int32_t dbgLen, int32_t stepCap, DeserStatsT *deser, FuzzRunStatsT *run) {
    +    fuzzWriteLast(sLastMod, mod, modLen);
    +    fuzzWriteLast(sLastDbg, dbg ? dbg : (const uint8_t *)"", dbg ? dbgLen : 0);
    +
    +    BasModuleT *m = basModuleDeserialize(mod, modLen);
    +
    +    if (!m) {
    +        deser->rejected++;
    +        return;
    +    }
    +
    +    deser->accepted++;
    +
    +    if (dbg) {
    +        basDebugDeserialize(m, dbg, dbgLen);
    +    }
    +
    +    fuzzRunModule(m, stepCap, run);
    +    basModuleFree(m);
    +}
    +
    +
    +static void usage(void) {
    +    fprintf(stderr, "Usage: fuzzModule [-n mutations] [-seed S] [-steps N] [-corpus DIR] [-leakcheck] [file.app ...]\n");
    +    fprintf(stderr, "  -leakcheck   run LeakSanitizer after every mutant and stop at the first leak (ASan builds)\n");
    +    fprintf(stderr, "  -dumpdir D   also write every module mutant to D/NNNNNN.bin\n");
    +}
    +
    +
    +int main(int argc, char **argv) {
    +    int32_t  mutations = FUZZ_DEFAULT_MUTATIONS;
    +    uint64_t seed      = FUZZ_DEFAULT_SEED;
    +    int32_t  stepCap   = FUZZ_DEFAULT_STEP_CAP;
    +    BlobT   *blobs     = NULL;
    +    int32_t  blobCount = 0;
    +    bool     leakCheck = false;
    +    const char *dumpDir = NULL;
    +
    +    for (int32_t i = 1; i < argc; i++) {
    +        if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) {
    +            mutations = (int32_t)strtol(argv[++i], NULL, 10);
    +        } else if (strcmp(argv[i], "-seed") == 0 && i + 1 < argc) {
    +            seed = strtoull(argv[++i], NULL, 10);
    +        } else if (strcmp(argv[i], "-steps") == 0 && i + 1 < argc) {
    +            stepCap = (int32_t)strtol(argv[++i], NULL, 10);
    +        } else if (strcmp(argv[i], "-corpus") == 0 && i + 1 < argc) {
    +            addCorpusBlobs(&blobs, &blobCount, argv[++i]);
    +        } else if (strcmp(argv[i], "-leakcheck") == 0) {
    +            leakCheck = true;
    +        } else if (strcmp(argv[i], "-dumpdir") == 0 && i + 1 < argc) {
    +            dumpDir = argv[++i];
    +        } else if (argv[i][0] != '-') {
    +            addAppBlob(&blobs, &blobCount, argv[i]);
    +        } else {
    +            usage();
    +            return 2;
    +        }
    +    }
    +
    +    if (blobCount == 0) {
    +        fprintf(stderr, "fuzzModule: nothing to fuzz\n");
    +        usage();
    +        return 2;
    +    }
    +
    +    FuzzRngT rng;
    +    fuzzRngSeed(&rng, seed);
    +
    +    sLastMod = fopen(FUZZ_LAST_FILE, "wb");
    +    sLastDbg = fopen(FUZZ_LAST_DBG_FILE, "wb");
    +
    +    DeserStatsT   deser   = { 0, 0 };
    +    FuzzRunStatsT run     = { 0, 0, 0 };
    +    int32_t       mutants = 0;
    +
    +    printf("DVX BASIC Bytecode Fuzz\n");
    +    printf("=======================\n");
    +    printf("blobs: %d, %d mutations each, seed %llu, step cap %d\n", (int)blobCount, (int)mutations, (unsigned long long)seed, (int)stepCap);
    +
    +    for (int32_t b = 0; b < blobCount; b++) {
    +        BlobT   *blob   = &blobs[b];
    +        uint8_t *modOut = (uint8_t *)malloc((size_t)blob->modLen + FUZZ_CHUNK_MAX);
    +        uint8_t *dbgOut = blob->dbg ? (uint8_t *)malloc((size_t)blob->dbgLen + FUZZ_CHUNK_MAX) : NULL;
    +
    +        if (!modOut || (blob->dbg && !dbgOut)) {
    +            fprintf(stderr, "fuzzModule: out of memory\n");
    +            return 2;
    +        }
    +
    +        // Unmutated first: the pristine blob must deserialize and run.
    +        int32_t acceptedBefore = deser.accepted;
    +        testBlob(blob->mod, blob->modLen, blob->dbg, blob->dbgLen, stepCap, &deser, &run);
    +
    +        if (deser.accepted == acceptedBefore) {
    +            printf("FAIL  %s: pristine module rejected by basModuleDeserialize\n", blob->name);
    +            return 1;
    +        }
    +
    +        for (int32_t m = 0; m < mutations; m++) {
    +            // Mutate the module most of the time, the debug blob otherwise.
    +            bool mutateDbg = blob->dbg && fuzzRngNext(&rng, 4) == 0;
    +            MutationE kind = (MutationE)fuzzRngNext(&rng, FUZZ_MUTATION_KINDS);
    +            int32_t   modLen = blob->modLen;
    +            int32_t   dbgLen = blob->dbgLen;
    +
    +            if (mutateDbg) {
    +                memcpy(modOut, blob->mod, (size_t)blob->modLen);
    +                dbgLen = mutate(&rng, blob->dbg, blob->dbgLen, dbgOut, kind);
    +            } else {
    +                modLen = mutate(&rng, blob->mod, blob->modLen, modOut, kind);
    +
    +                if (dbgOut) {
    +                    memcpy(dbgOut, blob->dbg, (size_t)blob->dbgLen);
    +                }
    +            }
    +
    +            mutants++;
    +
    +            if (dumpDir) {
    +                char path[DVX_MAX_PATH];
    +                snprintf(path, sizeof(path), "%s/%06d.bin", dumpDir, (int)mutants);
    +
    +                FILE *f = fopen(path, "wb");
    +
    +                if (f) {
    +                    fuzzWriteLast(f, modOut, modLen);
    +                    fclose(f);
    +                }
    +            }
    +
    +            testBlob(modOut, modLen, dbgOut, dbgLen, stepCap, &deser, &run);
    +
    +            if (leakCheck) {
    +                leakHistAdd(modOut, modLen, blob->name, m);
    +
    +                if (leaked()) {
    +                    printf("FAIL  leak detected after %s mutation %d; recent mutants:\n", blob->name, (int)m);
    +                    leakHistDump();
    +                    fflush(stdout);
    +                    return 1;
    +                }
    +            }
    +        }
    +
    +        free(modOut);
    +        free(dbgOut);
    +        free(blob->mod);
    +        free(blob->dbg);
    +    }
    +
    +    free(blobs);
    +
    +    if (sLastMod) {
    +        fclose(sLastMod);
    +    }
    +
    +    if (sLastDbg) {
    +        fclose(sLastDbg);
    +    }
    +
    +    remove(FUZZ_LAST_FILE);
    +    remove(FUZZ_LAST_DBG_FILE);
    +
    +    printf("mutants: %d  rejected: %d  accepted: %d  ran-to-end: %d  runtime-errors: %d  step-caps: %d\n", (int)mutants, (int)deser.rejected, (int)deser.accepted, (int)run.halted, (int)run.errors, (int)run.stepCaps);
    +    printf("fuzzModule: OK\n");
    +    return 0;
    +}
    diff --git a/src/test/basic/runBasic.sh b/src/test/basic/runBasic.sh
    new file mode 100755
    index 0000000..ee2af71
    --- /dev/null
    +++ b/src/test/basic/runBasic.sh
    @@ -0,0 +1,278 @@
    +#!/bin/bash
    +# 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.
    +
    +# runBasic.sh -- headless BASIC end-to-end regression test
    +#
    +# Three groups, all driven through bin/host/basrun:
    +#
    +#   samples   every src/test/samples/golden/*-debug.app is loaded and
    +#             run to completion (module-level code only; no forms) and
    +#             must exit 0 with no sanitizer report.
    +#
    +#   fixtures  every fixtures/*.bas is compiled and run in a fresh
    +#             scratch directory (App.Data points at it); stdout must
    +#             match fixtures/NAME.out and the exit code must match
    +#             fixtures/NAME.exit (default 0).  Optional NAME.in is fed
    +#             on stdin, optional NAME.args adds basrun options.
    +#
    +#   compact   every fixture is run again with --compact (strip +
    +#             compact the module) and every sample .dbp is compiled
    +#             both debug and release with bascomp, then also compiled
    +#             on the fly by basrun itself; output and exit code must be
    +#             identical across all three.
    +#
    +# Usage:
    +#   runBasic.sh                    run everything, non-zero exit on failure
    +#   UPDATE_GOLDENS=1 runBasic.sh   rewrite fixtures/*.out from current output
    +#
    +# Optional environment:
    +#   BASRUN      runner binary   (default: bin/host/basrun)
    +#   BASCOMP     compiler binary (default: bin/host/bascomp)
    +#   BASIC_OUT   scratch dir     (default: obj/test/basic)
    +
    +set -u
    +
    +scriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    +repoRoot="$(cd "$scriptDir/../../.." && pwd)"
    +basrun="${BASRUN:-$repoRoot/bin/host/basrun}"
    +bascomp="${BASCOMP:-$repoRoot/bin/host/bascomp}"
    +outDir="${BASIC_OUT:-$repoRoot/obj/test/basic}"
    +fixtureDir="$scriptDir/fixtures"
    +goldenDir="$repoRoot/src/test/samples/golden"
    +appDir="$repoRoot/src/apps/kpunch"
    +update="${UPDATE_GOLDENS:-0}"
    +
    +# Step cap for every run: catches accidental infinite loops without
    +# making a legitimately long fixture fail.
    +stepCap=2000000
    +
    +passCount=0
    +failCount=0
    +
    +
    +pass() {
    +    echo "PASS  $1"
    +    passCount=$((passCount + 1))
    +}
    +
    +
    +fail() {
    +    echo "FAIL  $1"
    +    shift
    +
    +    for line in "$@"; do
    +        echo "      $line"
    +    done
    +
    +    failCount=$((failCount + 1))
    +}
    +
    +
    +# runOne    
    +# Runs basrun from inside workdir; echoes the exit code.
    +runOne() {
    +    local work="$1"
    +    local out="$2"
    +    local in="$3"
    +    shift 3
    +
    +    rm -rf "$work"
    +    mkdir -p "$work"
    +    (cd "$work" && "$basrun" --steps "$stepCap" --data "$work" "$@" < "$in" > "$out" 2> "$out.err")
    +    echo $?
    +}
    +
    +
    +# runSample 
    +runSample() {
    +    local app="$1"
    +    local name="sample-$(basename "$app" .app)"
    +    local work="$outDir/$name"
    +    local rc
    +
    +    rc=$(runOne "$work" "$work.out" /dev/null "$app")
    +
    +    if [ "$rc" != "0" ]; then
    +        fail "$name" "exit $rc" "$(head -5 "$work.out.err")"
    +        return
    +    fi
    +
    +    if [ -s "$work.out.err" ]; then
    +        fail "$name" "unexpected stderr" "$(head -5 "$work.out.err")"
    +        return
    +    fi
    +
    +    pass "$name"
    +}
    +
    +
    +# runFixture 
    +runFixture() {
    +    local bas="$1"
    +    local name="$(basename "$bas" .bas)"
    +    local expectOut="$fixtureDir/$name.out"
    +    local expectExit=0
    +    local in=/dev/null
    +    local args=""
    +    local work="$outDir/fixture-$name"
    +    local rc
    +    local rcCompact
    +
    +    if [ -f "$fixtureDir/$name.exit" ]; then
    +        expectExit="$(cat "$fixtureDir/$name.exit")"
    +    fi
    +
    +    if [ -f "$fixtureDir/$name.in" ]; then
    +        in="$fixtureDir/$name.in"
    +    fi
    +
    +    if [ -f "$fixtureDir/$name.args" ]; then
    +        args="$(cat "$fixtureDir/$name.args")"
    +    fi
    +
    +    rc=$(runOne "$work" "$work.out" "$in" $args --quiet "$bas")
    +
    +    if grep -q "Sanitizer\|runtime error:\|compile error\|cannot read" "$work.out.err"; then
    +        fail "fixture-$name" "runner reported a problem" "$(head -5 "$work.out.err")"
    +        return
    +    fi
    +
    +    if [ "$update" = "1" ]; then
    +        cp "$work.out" "$expectOut"
    +        echo "GOLD  fixture-$name"
    +        passCount=$((passCount + 1))
    +        return
    +    fi
    +
    +    if [ ! -f "$expectOut" ]; then
    +        fail "fixture-$name" "no $name.out; run with UPDATE_GOLDENS=1"
    +        return
    +    fi
    +
    +    if [ "$rc" != "$expectExit" ]; then
    +        fail "fixture-$name" "exit $rc, expected $expectExit" "$(head -3 "$work.out.err")"
    +        return
    +    fi
    +
    +    if ! cmp -s "$work.out" "$expectOut"; then
    +        fail "fixture-$name" "stdout differs" "$(diff "$expectOut" "$work.out" | head -10)"
    +        return
    +    fi
    +
    +    pass "fixture-$name"
    +
    +    # Compaction differential: same program, stripped + compacted.
    +    rcCompact=$(runOne "$work-compact" "$work-compact.out" "$in" $args --quiet --compact "$bas")
    +
    +    if [ "$rcCompact" != "$rc" ]; then
    +        fail "compact-$name" "exit $rcCompact, plain exit $rc"
    +        return
    +    fi
    +
    +    if ! cmp -s "$work.out" "$work-compact.out"; then
    +        fail "compact-$name" "compacted output differs" "$(diff "$work.out" "$work-compact.out" | head -10)"
    +        return
    +    fi
    +
    +    pass "compact-$name"
    +}
    +
    +
    +# runProjectDiff 
    +# bascomp debug vs release (strip + obfuscate + compact) must run alike.
    +runProjectDiff() {
    +    local dbp="$1"
    +    local name="$(basename "$dbp" .dbp)"
    +    local work="$outDir/project-$name"
    +    local rcDebug
    +    local rcRelease
    +    local rcDirect
    +
    +    mkdir -p "$work"
    +
    +    if ! "$bascomp" "$dbp" -o "$work/debug.app" > "$work/bascomp-debug.log" 2>&1; then
    +        fail "project-$name" "bascomp debug failed" "$(tail -3 "$work/bascomp-debug.log")"
    +        return
    +    fi
    +
    +    if ! "$bascomp" "$dbp" -release -o "$work/release.app" > "$work/bascomp-release.log" 2>&1; then
    +        fail "project-$name" "bascomp release failed" "$(tail -3 "$work/bascomp-release.log")"
    +        return
    +    fi
    +
    +    rcDebug=$(runOne "$work/run-debug" "$work/debug.out" /dev/null --quiet "$work/debug.app")
    +    rcRelease=$(runOne "$work/run-release" "$work/release.out" /dev/null --quiet "$work/release.app")
    +
    +    if [ "$rcDebug" != "$rcRelease" ]; then
    +        fail "project-$name" "debug exit $rcDebug, release exit $rcRelease"
    +        return
    +    fi
    +
    +    if ! cmp -s "$work/debug.out" "$work/release.out"; then
    +        fail "project-$name" "release output differs" "$(diff "$work/debug.out" "$work/release.out" | head -10)"
    +        return
    +    fi
    +
    +    # basrun's own on-the-fly .dbp compile must agree with bascomp's .app.
    +    rcDirect=$(runOne "$work/run-direct" "$work/direct.out" /dev/null --quiet "$dbp")
    +
    +    if [ "$rcDirect" != "$rcDebug" ] || ! cmp -s "$work/debug.out" "$work/direct.out"; then
    +        fail "project-$name" "basrun .dbp run differs from bascomp .app (exit $rcDirect vs $rcDebug)"
    +        return
    +    fi
    +
    +    pass "project-$name (exit $rcDebug)"
    +}
    +
    +
    +if [ ! -x "$basrun" ] || [ ! -x "$bascomp" ]; then
    +    echo "runBasic: $basrun / $bascomp not found; build with make -C src/apps/kpunch/dvxbasic"
    +    exit 1
    +fi
    +
    +mkdir -p "$outDir"
    +
    +echo "DVX BASIC Headless Runs"
    +echo "======================="
    +
    +for app in "$goldenDir"/*-debug.app; do
    +    runSample "$app"
    +done
    +
    +for bas in "$fixtureDir"/*.bas; do
    +    runFixture "$bas"
    +done
    +
    +for dbp in $(find "$appDir" -name "*.dbp" | sort); do
    +    runProjectDiff "$dbp"
    +done
    +
    +echo
    +echo "-----------------------"
    +echo "PASS: $passCount   FAIL: $failCount"
    +
    +if [ "$failCount" -ne 0 ]; then
    +    exit 1
    +fi
    +
    +exit 0
    diff --git a/src/test/coverage/Makefile b/src/test/coverage/Makefile
    new file mode 100644
    index 0000000..f37b004
    --- /dev/null
    +++ b/src/test/coverage/Makefile
    @@ -0,0 +1,44 @@
    +# 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.
    +
    +# Phase 4 -- line coverage of the host test suites.
    +#
    +#   make -C src/test/coverage        # == make coverage from the repo root
    +#   make -C src/test/coverage clean  # drop obj/hostcov and obj/coverage
    +#
    +# runCoverage.sh rebuilds every host library, harness and suite with
    +# gcc --coverage (COV=1, SAN=0) under obj/hostcov, runs them all, and
    +# writes obj/coverage/html plus a per-directory summary.  lcov/genhtml
    +# is used when installed, else gcovr, else the bundled covReport.py
    +# (gcov JSON) which also produces the summary table in every case.
    +
    +ROOT = ../../..
    +
    +.PHONY: all run clean
    +
    +all: run
    +
    +run:
    +	./runCoverage.sh
    +
    +clean:
    +	rm -rf $(ROOT)/obj/hostcov $(ROOT)/obj/coverage
    diff --git a/src/test/coverage/__pycache__/covReport.cpython-312.pyc b/src/test/coverage/__pycache__/covReport.cpython-312.pyc
    new file mode 100644
    index 0000000000000000000000000000000000000000..3bf3314e81648f0a1ec74c67a30645cb60606633
    GIT binary patch
    literal 13065
    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
    DSo8vM
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/coverage/covReport.py b/src/test/coverage/covReport.py
    new file mode 100755
    index 0000000..3c5072f
    --- /dev/null
    +++ b/src/test/coverage/covReport.py
    @@ -0,0 +1,217 @@
    +#!/usr/bin/env python3
    +# 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.
    +
    +"""covReport.py -- line coverage summary (and fallback HTML) from gcov.
    +
    +Walks every .gcda under --objdir, asks gcov for JSON, merges the line
    +counts per source file (a line is hit if any object hit it), drops
    +thirdparty/ and src/test/ and anything outside the repo, then prints a
    +per-directory table.  With --html it also writes a self-contained HTML
    +report (index + one annotated page per file) for hosts without
    +lcov/genhtml or gcovr.
    +
    +    covReport.py --root REPO --objdir REPO/obj/hostcov [--html DIR] [--summary FILE]
    +"""
    +
    +import argparse
    +import html
    +import json
    +import os
    +import subprocess
    +import sys
    +
    +EXCLUDE_PARTS = ("/thirdparty/", "/src/test/")
    +PCT_WIDTH     = 6
    +
    +
    +def collectGcda(objDir):
    +    """Every .gcda below objDir."""
    +    found = []
    +    for dirPath, _dirs, files in os.walk(objDir):
    +        for name in files:
    +            if name.endswith(".gcda"):
    +                found.append(os.path.join(dirPath, name))
    +    return sorted(found)
    +
    +
    +def dirTable(files, root):
    +    """{relative dir: (hit, total)} plus ("TOTAL", ...)."""
    +    table = {}
    +    for path, lines in files.items():
    +        rel = os.path.relpath(os.path.dirname(path), root)
    +        hit = sum(1 for c in lines.values() if c > 0)
    +        total = len(lines)
    +        old = table.get(rel, (0, 0))
    +        table[rel] = (old[0] + hit, old[1] + total)
    +    return table
    +
    +
    +def formatPct(hit, total):
    +    if total == 0:
    +        return "   n/a"
    +    return "%5.1f%%" % (100.0 * hit / total)
    +
    +
    +def includeFile(path, root):
    +    if not path.startswith(root + os.sep):
    +        return False
    +    for part in EXCLUDE_PARTS:
    +        if part in path:
    +            return False
    +    return True
    +
    +
    +def mergeGcov(gcdaFiles, root):
    +    """{abs source path: {line: count}} merged across every object."""
    +    files = {}
    +    for gcda in gcdaFiles:
    +        proc = subprocess.run(["gcov", "--json-format", "--stdout", "-o", os.path.dirname(gcda), gcda], capture_output=True, text=True)
    +        if proc.returncode != 0 or not proc.stdout.strip():
    +            sys.stderr.write("covReport: gcov failed on %s: %s\n" % (gcda, proc.stderr.strip()))
    +            continue
    +        # gcov emits one JSON document per gcda given; a stray trailing
    +        # newline is the only extra content.
    +        for doc in proc.stdout.strip().split("\n"):
    +            data = json.loads(doc)
    +            cwd = data.get("current_working_directory", "")
    +            for entry in data.get("files", []):
    +                path = os.path.normpath(os.path.join(cwd, entry["file"]))
    +                if not includeFile(path, root):
    +                    continue
    +                lines = files.setdefault(path, {})
    +                for line in entry.get("lines", []):
    +                    num = line["line_number"]
    +                    lines[num] = max(lines.get(num, 0), line["count"])
    +    return files
    +
    +
    +def pageName(path, root):
    +    return os.path.relpath(path, root).replace(os.sep, "_") + ".html"
    +
    +
    +def writeFilePage(path, lines, root, htmlDir):
    +    rel = os.path.relpath(path, root)
    +    try:
    +        with open(path, "r", errors="replace") as handle:
    +            source = handle.read().split("\n")
    +    except OSError:
    +        source = []
    +    rows = []
    +    for idx, text in enumerate(source, start=1):
    +        count = lines.get(idx)
    +        if count is None:
    +            cls = "n"
    +            mark = ""
    +        elif count > 0:
    +            cls = "h"
    +            mark = str(count)
    +        else:
    +            cls = "m"
    +            mark = "0"
    +        rows.append('%d%s
    %s
    ' % (cls, idx, mark, html.escape(text))) + hit = sum(1 for c in lines.values() if c > 0) + body = [ + "%s" % html.escape(rel), + "" % STYLE, + '

    index

    ', + "

    %s

    " % html.escape(rel), + "

    %d of %d lines (%s)

    " % (hit, len(lines), formatPct(hit, len(lines)).strip()), + "", + ] + rows + ["
    "] + with open(os.path.join(htmlDir, pageName(path, root)), "w") as handle: + handle.write("\n".join(body)) + + +def writeHtml(files, root, htmlDir): + os.makedirs(htmlDir, exist_ok=True) + table = dirTable(files, root) + out = ["DVX host coverage", "" % STYLE, "

    DVX host coverage

    "] + out.append("") + totalHit = 0 + totalLines = 0 + for rel in sorted(table): + hit, total = table[rel] + totalHit += hit + totalLines += total + out.append("" % (html.escape(rel), total, hit, formatPct(hit, total))) + out.append("
    directorylineshitcoverage
    %s%d%d%s
    TOTAL%d%d%s
    " % (totalLines, totalHit, formatPct(totalHit, totalLines))) + out.append("

    Files

    ") + for path in sorted(files): + lines = files[path] + hit = sum(1 for c in lines.values() if c > 0) + out.append('' % (pageName(path, root), html.escape(os.path.relpath(path, root)), len(lines), hit, formatPct(hit, len(lines)))) + writeFilePage(path, lines, root, htmlDir) + out.append("
    filelineshitcoverage
    %s%d%d%s
    ") + with open(os.path.join(htmlDir, "index.html"), "w") as handle: + handle.write("\n".join(out)) + + +def writeSummary(files, root, summaryPath): + table = dirTable(files, root) + width = max([len(rel) for rel in table] + [len("directory")]) + text = [] + text.append("%-*s %7s %7s %*s" % (width, "directory", "lines", "hit", PCT_WIDTH, "cov")) + text.append("-" * (width + 2 + 7 + 2 + 7 + 2 + PCT_WIDTH)) + totalHit = 0 + totalLines = 0 + for rel in sorted(table): + hit, total = table[rel] + totalHit += hit + totalLines += total + text.append("%-*s %7d %7d %s" % (width, rel, total, hit, formatPct(hit, total))) + text.append("-" * (width + 2 + 7 + 2 + 7 + 2 + PCT_WIDTH)) + text.append("%-*s %7d %7d %s" % (width, "TOTAL", totalLines, totalHit, formatPct(totalHit, totalLines))) + report = "\n".join(text) + print(report) + if summaryPath: + with open(summaryPath, "w") as handle: + handle.write(report + "\n") + + +STYLE = "body{font-family:monospace}table{border-collapse:collapse}td,th{padding:1px 8px;text-align:left;vertical-align:top}pre{margin:0}tr.h{background:#d8f5d8}tr.m{background:#f8d0d0}tr.n{color:#777}" + + +def main(): + parser = argparse.ArgumentParser(description="gcov line coverage summary") + parser.add_argument("--root", required=True) + parser.add_argument("--objdir", required=True) + parser.add_argument("--html", default=None) + parser.add_argument("--summary", default=None) + args = parser.parse_args() + root = os.path.abspath(args.root) + gcdaFiles = collectGcda(os.path.abspath(args.objdir)) + if not gcdaFiles: + sys.stderr.write("covReport: no .gcda files under %s\n" % args.objdir) + return 1 + files = mergeGcov(gcdaFiles, root) + if not files: + sys.stderr.write("covReport: no in-tree sources found in the gcov data\n") + return 1 + if args.html: + writeHtml(files, root, args.html) + writeSummary(files, root, args.summary) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/test/coverage/runCoverage.sh b/src/test/coverage/runCoverage.sh new file mode 100755 index 0000000..cd2993e --- /dev/null +++ b/src/test/coverage/runCoverage.sh @@ -0,0 +1,150 @@ +#!/bin/sh +# 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. + +# runCoverage.sh -- build every host suite with gcc --coverage, run it, +# and produce a line-coverage report. +# +# Build tree: obj/hostcov/ (never shared with the SAN build in obj/host) +# Report: obj/coverage/html/index.html, obj/coverage/summary.txt +# +# Steps: +# 1. wipe obj/hostcov and obj/coverage +# 2. BASIC harnesses (test_compiler, test_compact, test_suite), the +# basrun-driven fixture runs and the sample goldens, all through +# instrumented bascomp/basrun (fuzzers skipped: FUZZ_N=0 equivalent) +# 3. every host suite: dvxtest, unit, wm, widgets, dialogs, formrt, ide, +# shell, serial -- built with SAN=0 COV=1 +# 4. report: lcov/genhtml if installed, else gcovr, else covReport.py +# (gcov --json-format); covReport.py always prints the per-directory +# summary. thirdparty/ and src/test/ are excluded. +# +# A failing suite is reported at the end; the report is still produced +# from whatever ran, and the exit status is non-zero. +# +# Requires: bin/host/dvxres and bin/host/bascomp (make tools; make apps) +# because bascomp embeds resources and the IDE suite compares against +# the release compiler. + +here=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$here/../../.." && pwd) +covObj="$root/obj/hostcov" +covOut="$root/obj/coverage" +basicHost="$covObj/basic" +basicSrc="$root/src/apps/kpunch/dvxbasic" +hostSuites="dvxtest sql unit wm widgets dialogs formrt ide shell" +failed="" + +# Same command-line variables to every make so the sub-makes agree. +covMake() { + make "$@" SAN=0 COV=1 +} + +# runStep NAME COMMAND... -- run one step, remember its name on failure. +runStep() { + stepName="$1" + shift + echo "==== coverage: $stepName" + if ! "$@"; then + echo "==== coverage: $stepName FAILED" + failed="$failed $stepName" + fi +} + +cd "$root" || exit 1 + +rm -rf "$covObj" "$covOut" +mkdir -p "$covObj" "$covOut" + +if [ ! -x bin/host/dvxres ]; then + echo "runCoverage: bin/host/dvxres missing; run make tools first" + exit 1 +fi + +# --- BASIC harnesses, fixtures, samples --------------------------------- +runStep "basic-build" covMake -C "$basicSrc" \ + ../../../../obj/hostcov/basic/test_compiler \ + ../../../../obj/hostcov/basic/test_compact \ + ../../../../obj/hostcov/basic/test_suite \ + ../../../../obj/hostcov/basic/bascomp \ + ../../../../obj/hostcov/basic/basrun +# The harnesses write scratch files into the working directory. +mkdir -p "$basicHost/work" +cd "$basicHost/work" || exit 1 +runStep "test_compiler" "$basicHost/test_compiler" +runStep "test_compact" "$basicHost/test_compact" +runStep "test_suite" "$basicHost/test_suite" +cd "$root" || exit 1 +runStep "basic-runs" env BASRUN="$basicHost/basrun" BASCOMP="$basicHost/bascomp" src/test/basic/runBasic.sh +runStep "samples" env BASCOMP="$basicHost/bascomp" src/test/samples/runSamples.sh +runStep "tools" env T9_COV=1 src/test/tools/runTools.sh + +# --- host GUI suites ------------------------------------------------------- +for suite in $hostSuites; do + runStep "$suite" covMake -C "src/test/$suite" +done + +# The serial suite has its own flat Makefile; CFLAGS is ?= there so the +# instrumentation goes in on the command line. +runStep "serial" make -C src/test/serial \ + CFLAGS="-O1 -g -Wall -Wextra -Werror --coverage" LDFLAGS="--coverage" \ + OBJDIR="$covObj/test/serial" TARGET="$covObj/test/serial/testSerial" + +# --- report --------------------------------------------------------------- +echo "==== coverage: report" +htmlDir="$covOut/html" +mkdir -p "$htmlDir" +excludes="*/thirdparty/* */src/test/* /usr/*" +tool="covReport.py" +if command -v lcov > /dev/null 2>&1 && command -v genhtml > /dev/null 2>&1; then + tool="lcov" + lcov --quiet --capture --directory "$covObj" --base-directory "$root" --output-file "$covOut/all.info" --rc branch_coverage=0 2> "$covOut/lcov.log" + # shellcheck disable=SC2086 + lcov --quiet --remove "$covOut/all.info" $excludes --output-file "$covOut/coverage.info" 2>> "$covOut/lcov.log" + genhtml --quiet --output-directory "$htmlDir" --title "DVX host coverage" "$covOut/coverage.info" 2>> "$covOut/lcov.log" +elif command -v gcovr > /dev/null 2>&1; then + tool="gcovr" + gcovr --root "$root" --object-directory "$covObj" \ + --exclude '.*/thirdparty/.*' --exclude '.*/src/test/.*' \ + --html --html-details --output "$htmlDir/index.html" > "$covOut/gcovr.log" 2>&1 +fi +# covReport.py always writes the per-directory summary (and the HTML +# report itself when neither lcov nor gcovr is available). +if [ "$tool" = "covReport.py" ]; then + "$here/covReport.py" --root "$root" --objdir "$covObj" --html "$htmlDir" --summary "$covOut/summary.txt" +else + "$here/covReport.py" --root "$root" --objdir "$covObj" --summary "$covOut/summary.txt" +fi +reportStatus=$? + +echo "" +echo "Coverage report ($tool): $htmlDir/index.html" +echo "Summary: $covOut/summary.txt" +if [ "$reportStatus" -ne 0 ]; then + failed="$failed report" +fi +if [ -n "$failed" ]; then + echo "FAILED steps:$failed" + exit 1 +fi +echo "All coverage steps passed." +exit 0 diff --git a/src/test/dialogs/Makefile b/src/test/dialogs/Makefile new file mode 100644 index 0000000..01f7a2a --- /dev/null +++ b/src/test/dialogs/Makefile @@ -0,0 +1,49 @@ +# 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. + +# T4 dialog suite: builds and runs dialogsTest against the host stack. +# SAN=1 (default) for ASan/UBSan; SAN=0 plain. UPDATE_GOLDENS=1 make run +# regenerates the golden crops under golden/. + +DVXTEST_GOLDEN_DIR := $(abspath golden) + +include ../dvxtest/dvxtest.mk + +.DEFAULT_GOAL := all + +TARGET = $(DVXTEST_BINDIR)/dialogsTest + +.PHONY: all lib run clean + +all: run + +lib: dvxtest-libs + +$(TARGET): dialogsTest.c lib + @mkdir -p $(DVXTEST_BINDIR) + $(HOSTCC) $(DVXTEST_CFLAGS) -DDIALOGS_GOLDEN_DIR='"$(CURDIR)/golden"' -o $@ dialogsTest.c $(DVXTEST_LINK) + +run: $(TARGET) + cd $(DVXTEST_BINDIR) && ASAN_OPTIONS=detect_leaks=1 ./dialogsTest + +clean: + rm -f $(TARGET) diff --git a/src/test/dialogs/dialogsTest.c b/src/test/dialogs/dialogsTest.c new file mode 100644 index 0000000..15dc566 --- /dev/null +++ b/src/test/dialogs/dialogsTest.c @@ -0,0 +1,1460 @@ +// 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. + +// dialogsTest.c -- T4: modal dialogs driven through tDialogScript +// +// Every blocking dialog (message/input/int/choice/file) runs its own +// nested dvxUpdate loop, so each case installs an idle script that +// finds the modal window and drives it with queued input. A script +// may pump nested frames (tClick, tType, ...) from inside the idle +// callback; the busy stack below keeps a script from re-entering +// itself for the SAME modal while still letting it answer a nested +// dialog (confirmation boxes opened by the file dialog). + +#include "dvxtest.h" +#include "dvxWm.h" +#include "dvxWgtP.h" +#include "box/box.h" +#include "button/button.h" +#include "listBox/listBox.h" +#include "textInput/textInpt.h" + +#include +#include +#include +#include +#include + +#define SCREEN_W 640 +#define SCREEN_H 480 +#define SCREEN_W_LARGE 800 +#define SCREEN_H_LARGE 600 +#define SCREEN_BPP 32 + +#define SETTLE_FRAMES 3 +#define CENTER_TOLERANCE 1 +#define BUSY_STACK_MAX 8 +#define TEXT_MAX_LEN 64 +#define SMALL_BUF_LEN 4 +#define OUT_PATH_LEN 512 + +// Message box geometry mirrored from dvxDialog.c (not exported) +#define MSG_PADDING 8 +#define MSG_MAX_WIDTH 320 +#define MSG_MAX_WIDTH_FRAME (MSG_MAX_WIDTH + CHROME_TOTAL_SIDE * 2) +#define BUTTON_HEIGHT 24 +#define LISTBOX_BORDER 2 +#define SPINNER_BORDER 2 +#define SPINNER_BTN_W 14 + +// Spinner fixture +#define SPIN_DEFAULT 5 +#define SPIN_MIN 0 +#define SPIN_MAX 10 +#define SPIN_STEP 2 + +// Choice fixture +#define CHOICE_COUNT 4 +#define CHOICE_DEFAULT 1 + +// Fixture tree entries: "..", "[sub]", a.txt, b.txt, c.bmp +#define FIXTURE_ENTRIES_ALL 5 +#define FIXTURE_ENTRIES_TXT 4 +#define FIXTURE_ENTRIES_BMP 3 +#define FIXTURE_IDX_PARENT 0 +#define FIXTURE_IDX_SUB 1 +#define FIXTURE_IDX_ATXT 2 +#define FIXTURE_SUB_ENTRIES 3 // "..", "[deep]", d.txt + +#define SPIN_OVERRANGE_TEXT "999" +#define SPIN_UNDERRANGE_TEXT "-55" +#define BACKSPACE_CLEAR_COUNT 4 + +#define OTHER_WIN_X 20 +#define OTHER_WIN_Y 20 +#define OTHER_WIN_W 240 +#define OTHER_WIN_H 160 + +// The runner bakes its own golden directory into libdvxtest.a, so the +// crop name walks from there to this suite's golden/ (see NEEDS in the +// T4 report: a per-suite golden dir setter). +#define GOLDEN_MSGBOX "msgBoxInfo32" + +#define DBLCLICK_GAP_MS 10 +#define SINGLE_CLICK_GAP_MS (MOUSE_DBLCLICK_DEFAULT_MS + 1) + + +// ============================================================ +// Types +// ============================================================ + +typedef struct { + int32_t flags; + const char *labels[3]; + int32_t ids[3]; + int32_t count; +} ButtonSetT; + +typedef struct { + const char *button; // caption to click on the outer dialog (NULL = none) + const char *nestedButton; // caption to click on any nested dialog + const char *typeText; // typed into the focused widget first (NULL = none) + bool pressEnter; // press Enter instead of clicking + bool pressEsc; // press Escape instead of clicking + bool closeGadget; // click the title-bar close gadget + bool abandon; // clear ctx->running + char title[MAX_TITLE_LEN]; + int32_t calls; + int32_t winX; + int32_t winY; + int32_t winW; + int32_t winH; + int32_t contentH; + WindowT *modalSeen; + WindowT *prevModalAfterNested; + int32_t nestedResult; + char captured[DVX_MAX_PATH]; + int32_t capturedInt; + int32_t capturedInt2; + const char *goldenName; +} ScriptT; + +typedef struct { + WindowT *win; + WidgetT *btn; + WidgetT *input; +} OtherWindowT; + +typedef enum { + FileOpCancelE, + FileOpEscE, + FileOpCloseE, + FileOpDblClickSubE, + FileOpTypePathE, + FileOpFilterNextE, + FileOpClickFileOpenE, + FileOpTypeNameE, + FileOpDblClickFileE, + FileOpSelectDirOpenE, + FileOpEnterInNameE +} FileOpE; + +typedef struct { + FileOpE op; + bool acted; // step 0 done; next idle call records and cancels + const char *nestedButton; + const char *text; + char pathAfter[DVX_MAX_PATH]; + char nameAfter[DVX_MAX_PATH]; + int32_t itemsAfter; + int32_t itemsBefore; + bool stillModalAfter; + bool nestedSeen; + char nestedTitle[MAX_TITLE_LEN]; +} FileScriptT; + + +// ============================================================ +// Prototypes +// ============================================================ + +static void caseAbandon(void); +static void caseChoice(void); +static void caseErrorInfoBox(void); +static void caseFileDialog(void); +static void caseGeometry(int32_t w, int32_t h); +static void caseGolden(void); +static void caseInputBox(void); +static void caseIntInputBox(void); +static void caseMessageBoxMatrix(void); +static void caseModality(void); +static void caseNested(void); +static void casePromptSave(void); +static void caseWrapping(void); +static WidgetT *childAt(WidgetT *parent, int32_t idx); +static void clickCloseGadget(WindowT *win); +static void clickListRow(WidgetT *lb, int32_t row, bool dbl); +static void clickSpinnerArrow(WidgetT *sp, bool up); +static void fileScript(AppContextT *ctx, WindowT *modal, void *userCtx); +static void fileSelectRow(WidgetT *lb, int32_t row); +static void fixtureTree(char *dir, int32_t dirSize); +static void nestedScript(AppContextT *ctx, WindowT *modal, void *userCtx); +static OtherWindowT otherWindow(const char *title); +static bool scriptEnter(WindowT *modal); +static void scriptInit(ScriptT *s, const char *button); +static void scriptLeave(void); +static void scriptRecord(ScriptT *s, WindowT *modal); +static void spinInit(ScriptT *s, const char *button); +static void spinScript(AppContextT *ctx, WindowT *modal, void *userCtx); +static void uniformScript(AppContextT *ctx, WindowT *modal, void *userCtx); + + +// ============================================================ +// Module state +// ============================================================ + +static WindowT *sBusy[BUSY_STACK_MAX]; +static int32_t sBusyDepth = 0; + +static const ButtonSetT sButtonSets[] = { + { MB_OK, { "OK", NULL, NULL }, { ID_OK, 0, 0 }, 1 }, + { MB_OKCANCEL, { "OK", "Cancel", NULL }, { ID_OK, ID_CANCEL, 0 }, 2 }, + { MB_YESNO, { "Yes", "No", NULL }, { ID_YES, ID_NO, 0 }, 2 }, + { MB_YESNOCANCEL, { "Yes", "No", "Cancel" }, { ID_YES, ID_NO, ID_CANCEL }, 3 }, + { MB_RETRYCANCEL, { "Retry", "Cancel", NULL }, { ID_RETRY, ID_CANCEL, 0 }, 2 }, +}; + +static const int32_t sIcons[] = { 0, MB_ICONINFO, MB_ICONWARNING, MB_ICONERROR, MB_ICONQUESTION }; + +static const char *sChoices[CHOICE_COUNT] = { "Alpha", "Beta", "Gamma", "Delta" }; + +static const FileFilterT sFiltersTxt[] = { { "Text Files (*.txt)" }, { "All Files (*.*)" } }; +static const FileFilterT sFiltersMulti[] = { { "Text and Images (*.txt;*.bmp)" }, { "Bitmaps (*.bmp)" } }; + + +// ============================================================ +// Cases +// ============================================================ + +static void caseAbandon(void) { + tCase("abandon mid-loop leaves no state behind"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + ScriptT s; + + scriptInit(&s, NULL); + s.abandon = true; + tDialogScript(uniformScript, &s); + + int32_t result = dvxMessageBox(tCtx(), "Abandon", "Going away", MB_OKCANCEL); + + TEST_EQ(result, ID_CANCEL); + TEST_TRUE(!tCtx()->running); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + + tCtx()->running = true; + tPump(1); + TEST_PTR_EQ(tFindWindow("Abandon"), NULL); + TEST_EQ(tCtx()->stack.count, 0); + + // Reset clears the dialog statics; a fresh dialog must work normally. + dvxDialogTestReset(); + scriptInit(&s, "OK"); + result = dvxMessageBox(tCtx(), "After", "Back again", MB_OKCANCEL); + TEST_EQ(result, ID_OK); + + // Abandon a file dialog too (it owns heap entries). + char dir[DVX_MAX_PATH]; + char out[OUT_PATH_LEN]; + + fixtureTree(dir, sizeof(dir)); + scriptInit(&s, NULL); + s.abandon = true; + TEST_TRUE(!dvxFileDialog(tCtx(), "Gone", FD_OPEN, dir, sFiltersTxt, 2, out, sizeof(out))); + tCtx()->running = true; + tPump(1); + TEST_PTR_EQ(tFindWindow("Gone"), NULL); + dvxDialogTestReset(); + + tShutdown(); +} + + +static void caseChoice(void) { + tCase("choice dialog"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + ScriptT s; + int32_t idx = -1; + + // Default index is highlighted and returned by OK. + scriptInit(&s, "OK"); + TEST_TRUE(dvxChoiceDialog(tCtx(), "Pick", "Pick one:", sChoices, CHOICE_COUNT, CHOICE_DEFAULT, &idx)); + TEST_EQ(idx, CHOICE_DEFAULT); + TEST_EQ(s.capturedInt, CHOICE_DEFAULT); + + // Keyboard: Down twice from the default then OK. + scriptInit(&s, "OK"); + s.capturedInt2 = 2; + idx = -1; + TEST_TRUE(dvxChoiceDialog(tCtx(), "Pick", "Pick one:", sChoices, CHOICE_COUNT, CHOICE_DEFAULT, &idx)); + TEST_EQ(idx, CHOICE_DEFAULT + 2); + + // Enter on the list accepts the highlighted item. + scriptInit(&s, NULL); + s.pressEnter = true; + idx = -1; + TEST_TRUE(dvxChoiceDialog(tCtx(), "Pick", "Pick one:", sChoices, CHOICE_COUNT, 0, &idx)); + TEST_EQ(idx, 0); + + // Double-click on the last row accepts it. + scriptInit(&s, NULL); + s.capturedInt2 = -(CHOICE_COUNT - 1) - 1; + idx = -1; + TEST_TRUE(dvxChoiceDialog(tCtx(), "Pick", "Pick one:", sChoices, CHOICE_COUNT, 0, &idx)); + TEST_EQ(idx, CHOICE_COUNT - 1); + + // Cancel, Escape and the close gadget all reject. + idx = 7; + scriptInit(&s, "Cancel"); + TEST_TRUE(!dvxChoiceDialog(tCtx(), "Pick", "Pick one:", sChoices, CHOICE_COUNT, CHOICE_DEFAULT, &idx)); + TEST_EQ(idx, 7); + scriptInit(&s, NULL); + s.pressEsc = true; + TEST_TRUE(!dvxChoiceDialog(tCtx(), "Pick", "Pick one:", sChoices, CHOICE_COUNT, CHOICE_DEFAULT, &idx)); + scriptInit(&s, NULL); + s.closeGadget = true; + TEST_TRUE(!dvxChoiceDialog(tCtx(), "Pick", "Pick one:", sChoices, CHOICE_COUNT, CHOICE_DEFAULT, &idx)); + TEST_EQ(idx, 7); + + // defaultIdx -1: the list box highlights its first item anyway. + scriptInit(&s, "OK"); + TEST_TRUE(dvxChoiceDialog(tCtx(), "Pick", "Pick one:", sChoices, CHOICE_COUNT, -1, &idx)); + TEST_EQ(idx, 0); + TEST_EQ(s.capturedInt, 0); + + // Bad arguments + TEST_TRUE(!dvxChoiceDialog(tCtx(), "Pick", "Pick one:", sChoices, 0, 0, &idx)); + TEST_TRUE(!dvxChoiceDialog(tCtx(), "Pick", "Pick one:", NULL, CHOICE_COUNT, 0, &idx)); + TEST_TRUE(!dvxChoiceDialog(tCtx(), "Pick", "Pick one:", sChoices, CHOICE_COUNT, 0, NULL)); + + tPump(1); + TEST_PTR_EQ(tFindWindow("Pick"), NULL); + tShutdown(); +} + + +static void caseErrorInfoBox(void) { + tCase("error/info box titles"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + ScriptT s; + + scriptInit(&s, "OK"); + TEST_EQ(dvxErrorBox(tCtx(), NULL, "Boom"), ID_OK); + TEST_STR_EQ(s.title, "Error"); + + scriptInit(&s, "OK"); + TEST_EQ(dvxErrorBox(tCtx(), "Custom", "Boom"), ID_OK); + TEST_STR_EQ(s.title, "Custom"); + + scriptInit(&s, "OK"); + TEST_EQ(dvxInfoBox(tCtx(), NULL, "FYI"), ID_OK); + TEST_STR_EQ(s.title, "Information"); + + scriptInit(&s, "OK"); + TEST_EQ(dvxInfoBox(tCtx(), "Note", "FYI"), ID_OK); + TEST_STR_EQ(s.title, "Note"); + + // Only one button exists; Cancel is absent. + scriptInit(&s, "Cancel"); + s.nestedButton = "OK"; + TEST_EQ(dvxInfoBox(tCtx(), "Note", "FYI"), ID_OK); + + // NULL message / ctx are rejected without a window. + TEST_EQ(dvxMessageBox(tCtx(), "X", NULL, MB_OK), ID_CANCEL); + TEST_EQ(dvxMessageBox(NULL, "X", "m", MB_OK), ID_CANCEL); + + tShutdown(); +} + + +static void caseFileDialog(void) { + tCase("file dialog"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + char dir[DVX_MAX_PATH]; + char sub[DVX_MAX_PATH]; + char expect[OUT_PATH_LEN]; + char out[OUT_PATH_LEN]; + FileScriptT f; + char real[PATH_MAX]; + + fixtureTree(dir, sizeof(dir)); + + if (!realpath(dir, real)) { + snprintf(real, sizeof(real), "%s", dir); + } + + snprintf(sub, sizeof(sub), "%s/sub", real); + tDialogScript(fileScript, &f); + + // Cancel / Escape / close gadget reject and leave outPath alone. + memset(&f, 0, sizeof(f)); + f.op = FileOpCancelE; + strcpy(out, "untouched"); + TEST_TRUE(!dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + TEST_STR_EQ(out, "untouched"); + TEST_EQ(f.itemsBefore, FIXTURE_ENTRIES_ALL); + TEST_STR_EQ(f.pathAfter, real); + + memset(&f, 0, sizeof(f)); + f.op = FileOpEscE; + TEST_TRUE(!dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + memset(&f, 0, sizeof(f)); + f.op = FileOpCloseE; + TEST_TRUE(!dvxFileDialog(tCtx(), NULL, FD_OPEN, dir, NULL, 0, out, sizeof(out))); + tPump(1); + TEST_PTR_EQ(tFindWindow("Open"), NULL); + + // Default titles + memset(&f, 0, sizeof(f)); + f.op = FileOpCancelE; + TEST_TRUE(!dvxFileDialog(tCtx(), NULL, FD_SAVE, dir, NULL, 0, out, sizeof(out))); + TEST_PTR_EQ(tFindWindow("Save As"), NULL); + + // Double-click on "[sub]" navigates; the dialog stays up. + memset(&f, 0, sizeof(f)); + f.op = FileOpDblClickSubE; + TEST_TRUE(!dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + TEST_STR_EQ(f.pathAfter, sub); + TEST_TRUE(f.stillModalAfter); + TEST_EQ(f.itemsAfter, FIXTURE_SUB_ENTRIES); + + // Typing a path + Enter in the path input navigates. + memset(&f, 0, sizeof(f)); + f.op = FileOpTypePathE; + f.text = sub; + TEST_TRUE(!dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + TEST_STR_EQ(f.pathAfter, sub); + TEST_TRUE(f.stillModalAfter); + + // Filters: single glob, then switching to the next filter. + memset(&f, 0, sizeof(f)); + f.op = FileOpFilterNextE; + TEST_TRUE(!dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, sFiltersTxt, 2, out, sizeof(out))); + TEST_EQ(f.itemsBefore, FIXTURE_ENTRIES_TXT); + TEST_EQ(f.itemsAfter, FIXTURE_ENTRIES_ALL); + + memset(&f, 0, sizeof(f)); + f.op = FileOpFilterNextE; + TEST_TRUE(!dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, sFiltersMulti, 2, out, sizeof(out))); + TEST_EQ(f.itemsBefore, FIXTURE_ENTRIES_ALL); + TEST_EQ(f.itemsAfter, FIXTURE_ENTRIES_BMP); + + // Open: click a.txt (name input follows), then Open. + memset(&f, 0, sizeof(f)); + f.op = FileOpClickFileOpenE; + out[0] = '\0'; + TEST_TRUE(dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + snprintf(expect, sizeof(expect), "%s/a.txt", dir); + TEST_STR_EQ(out, expect); + TEST_STR_EQ(f.nameAfter, "a.txt"); + + // Double-click on a file accepts it. + memset(&f, 0, sizeof(f)); + f.op = FileOpDblClickFileE; + out[0] = '\0'; + TEST_TRUE(dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + TEST_STR_EQ(out, expect); + + // Enter in the filename input is the default button. + memset(&f, 0, sizeof(f)); + f.op = FileOpEnterInNameE; + f.text = "b.txt"; + out[0] = '\0'; + TEST_TRUE(dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + snprintf(expect, sizeof(expect), "%s/b.txt", dir); + TEST_STR_EQ(out, expect); + + // Open of a missing file asks to create it: Yes accepts, No stays. + memset(&f, 0, sizeof(f)); + f.op = FileOpTypeNameE; + f.text = "zz.txt"; + f.nestedButton = "Yes"; + out[0] = '\0'; + TEST_TRUE(dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + snprintf(expect, sizeof(expect), "%s/zz.txt", dir); + TEST_STR_EQ(out, expect); + TEST_STR_EQ(f.nestedTitle, "Confirm Create"); + + memset(&f, 0, sizeof(f)); + f.op = FileOpTypeNameE; + f.text = "zz.txt"; + f.nestedButton = "No"; + TEST_TRUE(!dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + TEST_TRUE(f.stillModalAfter); + TEST_TRUE(f.nestedSeen); + + // Save over an existing file asks to replace it. + memset(&f, 0, sizeof(f)); + f.op = FileOpTypeNameE; + f.text = "a.txt"; + f.nestedButton = "Yes"; + out[0] = '\0'; + TEST_TRUE(dvxFileDialog(tCtx(), "Save It", FD_SAVE, dir, NULL, 0, out, sizeof(out))); + snprintf(expect, sizeof(expect), "%s/a.txt", dir); + TEST_STR_EQ(out, expect); + TEST_STR_EQ(f.nestedTitle, "Confirm Save"); + + memset(&f, 0, sizeof(f)); + f.op = FileOpTypeNameE; + f.text = "a.txt"; + f.nestedButton = "No"; + TEST_TRUE(!dvxFileDialog(tCtx(), "Save It", FD_SAVE, dir, NULL, 0, out, sizeof(out))); + TEST_TRUE(f.stillModalAfter); + + // Save of a new name without extension appends the filter's extension. + memset(&f, 0, sizeof(f)); + f.op = FileOpTypeNameE; + f.text = "fresh"; + out[0] = '\0'; + TEST_TRUE(dvxFileDialog(tCtx(), "Save It", FD_SAVE, dir, sFiltersTxt, 2, out, sizeof(out))); + snprintf(expect, sizeof(expect), "%s/fresh.txt", dir); + TEST_STR_EQ(out, expect); + TEST_TRUE(!f.nestedSeen); + + // Save with a wildcard filter leaves the name alone. + memset(&f, 0, sizeof(f)); + f.op = FileOpTypeNameE; + f.text = "plain"; + out[0] = '\0'; + TEST_TRUE(dvxFileDialog(tCtx(), "Save It", FD_SAVE, dir, &sFiltersTxt[1], 1, out, sizeof(out))); + snprintf(expect, sizeof(expect), "%s/plain", dir); + TEST_STR_EQ(out, expect); + + // Invalid filename: error box, then the dialog is still up. + memset(&f, 0, sizeof(f)); + f.op = FileOpTypeNameE; + f.text = "bad/name"; + f.nestedButton = "OK"; + TEST_TRUE(!dvxFileDialog(tCtx(), "Save It", FD_SAVE, dir, NULL, 0, out, sizeof(out))); + TEST_STR_EQ(f.nestedTitle, "Invalid Filename"); + TEST_TRUE(f.stillModalAfter); + + // Selecting a directory and pressing Open navigates, never accepts. + memset(&f, 0, sizeof(f)); + f.op = FileOpSelectDirOpenE; + TEST_TRUE(!dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + TEST_STR_EQ(f.pathAfter, sub); + TEST_TRUE(f.stillModalAfter); + + // Typing a directory name and pressing Open navigates too. + memset(&f, 0, sizeof(f)); + f.op = FileOpTypeNameE; + f.text = "sub"; + TEST_TRUE(!dvxFileDialog(tCtx(), "Open It", FD_OPEN, dir, NULL, 0, out, sizeof(out))); + TEST_STR_EQ(f.pathAfter, sub); + TEST_TRUE(f.stillModalAfter); + TEST_TRUE(!f.nestedSeen); + + // Bad arguments + TEST_TRUE(!dvxFileDialog(tCtx(), "X", FD_OPEN, dir, NULL, 0, NULL, 0)); + TEST_TRUE(!dvxFileDialog(tCtx(), "X", FD_OPEN, dir, NULL, 0, out, 0)); + + tPump(1); + TEST_PTR_EQ(tFindWindow("Open It"), NULL); + TEST_PTR_EQ(tFindWindow("Save It"), NULL); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + tShutdown(); +} + + +static void caseGeometry(int32_t w, int32_t h) { + tCase("dialogs are centered"); + tInit(w, h, SCREEN_BPP); + + ScriptT s; + char dir[DVX_MAX_PATH]; + char out[OUT_PATH_LEN]; + char buf[TEXT_MAX_LEN]; + int32_t val = 0; + int32_t idx = 0; + + fixtureTree(dir, sizeof(dir)); + + scriptInit(&s, "OK"); + dvxMessageBox(tCtx(), "Geo", "Centered?", MB_OK | MB_ICONINFO); + TEST_TRUE(abs(s.winX + s.winW / 2 - w / 2) <= CENTER_TOLERANCE); + TEST_TRUE(abs(s.winY + s.winH / 2 - h / 2) <= CENTER_TOLERANCE); + + scriptInit(&s, "Cancel"); + dvxInputBox(tCtx(), "Geo", "Prompt", NULL, buf, sizeof(buf)); + TEST_TRUE(abs(s.winX + s.winW / 2 - w / 2) <= CENTER_TOLERANCE); + TEST_TRUE(abs(s.winY + s.winH / 2 - h / 2) <= CENTER_TOLERANCE); + + scriptInit(&s, "Cancel"); + dvxIntInputBox(tCtx(), "Geo", "Prompt", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, &val); + TEST_TRUE(abs(s.winX + s.winW / 2 - w / 2) <= CENTER_TOLERANCE); + TEST_TRUE(abs(s.winY + s.winH / 2 - h / 2) <= CENTER_TOLERANCE); + + scriptInit(&s, "Cancel"); + dvxChoiceDialog(tCtx(), "Geo", "Prompt", sChoices, CHOICE_COUNT, 0, &idx); + TEST_TRUE(abs(s.winX + s.winW / 2 - w / 2) <= CENTER_TOLERANCE); + TEST_TRUE(abs(s.winY + s.winH / 2 - h / 2) <= CENTER_TOLERANCE); + + scriptInit(&s, "Cancel"); + dvxFileDialog(tCtx(), "Geo", FD_OPEN, dir, NULL, 0, out, sizeof(out)); + TEST_TRUE(abs(s.winX + s.winW / 2 - w / 2) <= CENTER_TOLERANCE); + TEST_TRUE(abs(s.winY + s.winH / 2 - h / 2) <= CENTER_TOLERANCE); + + tShutdown(); +} + + +static void caseGolden(void) { + tCase("message box golden crop (32bpp)"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + ScriptT s; + + scriptInit(&s, "OK"); + s.goldenName = GOLDEN_MSGBOX; + TEST_EQ(dvxMessageBox(tCtx(), "Golden", "Golden crop of a message box.", MB_OKCANCEL | MB_ICONINFO), ID_OK); + TEST_TRUE(s.calls >= 1); + tShutdown(); +} + + +static void caseInputBox(void) { + tCase("input box"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + ScriptT s; + char buf[TEXT_MAX_LEN]; + char small[SMALL_BUF_LEN]; + + // Default text is returned untouched by OK. + scriptInit(&s, "OK"); + strcpy(buf, "old"); + TEST_TRUE(dvxInputBox(tCtx(), "Ask", "Name?", "default", buf, sizeof(buf))); + TEST_STR_EQ(buf, "default"); + TEST_STR_EQ(s.captured, "default"); + + // No default: empty string on OK. + scriptInit(&s, "OK"); + strcpy(buf, "old"); + TEST_TRUE(dvxInputBox(tCtx(), "Ask", "Name?", NULL, buf, sizeof(buf))); + TEST_STR_EQ(buf, ""); + + // Typed text (the input has focus) is appended and accepted by OK. + scriptInit(&s, "OK"); + s.typeText = " Jr."; + TEST_TRUE(dvxInputBox(tCtx(), "Ask", "Name?", "Scott", buf, sizeof(buf))); + TEST_STR_EQ(buf, "Scott Jr."); + + // Enter accepts, Escape cancels, close gadget cancels. + scriptInit(&s, NULL); + s.typeText = "typed"; + s.pressEnter = true; + TEST_TRUE(dvxInputBox(tCtx(), "Ask", "Name?", NULL, buf, sizeof(buf))); + TEST_STR_EQ(buf, "typed"); + + scriptInit(&s, NULL); + s.typeText = "lost"; + s.pressEsc = true; + strcpy(buf, "kept"); + TEST_TRUE(!dvxInputBox(tCtx(), "Ask", "Name?", NULL, buf, sizeof(buf))); + TEST_STR_EQ(buf, "kept"); + + scriptInit(&s, "Cancel"); + TEST_TRUE(!dvxInputBox(tCtx(), "Ask", "Name?", "x", buf, sizeof(buf))); + TEST_STR_EQ(buf, "kept"); + + scriptInit(&s, NULL); + s.closeGadget = true; + TEST_TRUE(!dvxInputBox(tCtx(), "Ask", "Name?", "x", buf, sizeof(buf))); + TEST_STR_EQ(buf, "kept"); + + // Buffer bound: result is truncated and terminated. + scriptInit(&s, "OK"); + memset(small, 'Z', sizeof(small)); + TEST_TRUE(dvxInputBox(tCtx(), "Ask", "Name?", "longer than four", small, sizeof(small))); + TEST_STR_EQ(small, "lon"); + + // Default title when NULL + scriptInit(&s, "Cancel"); + dvxInputBox(tCtx(), NULL, "Name?", NULL, buf, sizeof(buf)); + TEST_STR_EQ(s.title, "Input"); + + // Bad arguments + TEST_TRUE(!dvxInputBox(tCtx(), "Ask", "Name?", NULL, NULL, 0)); + TEST_TRUE(!dvxInputBox(tCtx(), "Ask", "Name?", NULL, buf, 0)); + + tPump(1); + TEST_PTR_EQ(tFindWindow("Ask"), NULL); + tShutdown(); +} + + +static void caseIntInputBox(void) { + tCase("integer input box"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + ScriptT s; + int32_t val = -1; + + // Default value through OK. + spinInit(&s, "OK"); + TEST_TRUE(dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, &val)); + TEST_EQ(val, SPIN_DEFAULT); + + // Up arrow twice (step 2), then the value is clamped at max on the third. + spinInit(&s, "OK"); + s.capturedInt = 3; + TEST_TRUE(dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, &val)); + TEST_EQ(val, SPIN_MAX); + + spinInit(&s, "OK"); + s.capturedInt = 1; + TEST_TRUE(dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, &val)); + TEST_EQ(val, SPIN_DEFAULT + SPIN_STEP); + + // Down arrows clamp at min. + spinInit(&s, "OK"); + s.capturedInt = -4; + TEST_TRUE(dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, &val)); + TEST_EQ(val, SPIN_MIN); + + // Keyboard Up/Down on the focused spinner. + spinInit(&s, "OK"); + s.capturedInt2 = 1; + TEST_TRUE(dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, &val)); + TEST_EQ(val, SPIN_DEFAULT + SPIN_STEP); + + // Typed out-of-range values are clamped on Enter (commit + default OK). + spinInit(&s, NULL); + s.typeText = SPIN_OVERRANGE_TEXT; + s.pressEnter = true; + TEST_TRUE(dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, &val)); + TEST_EQ(val, SPIN_MAX); + + spinInit(&s, "OK"); + s.typeText = SPIN_UNDERRANGE_TEXT; + TEST_TRUE(dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, -SPIN_MAX, SPIN_MAX, SPIN_STEP, &val)); + TEST_EQ(val, -SPIN_MAX); + + // Cancel / Escape / close leave the output alone. + val = 42; + spinInit(&s, "Cancel"); + TEST_TRUE(!dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, &val)); + spinInit(&s, NULL); + s.pressEsc = true; + TEST_TRUE(!dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, &val)); + spinInit(&s, NULL); + s.closeGadget = true; + TEST_TRUE(!dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, &val)); + TEST_EQ(val, 42); + + TEST_TRUE(!dvxIntInputBox(tCtx(), "Int", "How many?", SPIN_DEFAULT, SPIN_MIN, SPIN_MAX, SPIN_STEP, NULL)); + + tPump(1); + TEST_PTR_EQ(tFindWindow("Int"), NULL); + tShutdown(); +} + + +static void caseMessageBoxMatrix(void) { + tCase("message box button sets x icons"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + ScriptT s; + int32_t setCount = (int32_t)(sizeof(sButtonSets) / sizeof(sButtonSets[0])); + int32_t iconCount = (int32_t)(sizeof(sIcons) / sizeof(sIcons[0])); + + for (int32_t si = 0; si < setCount; si++) { + const ButtonSetT *set = &sButtonSets[si]; + + for (int32_t ii = 0; ii < iconCount; ii++) { + int32_t flags = set->flags | sIcons[ii]; + + for (int32_t b = 0; b < set->count; b++) { + scriptInit(&s, set->labels[b]); + TEST_EQ(dvxMessageBox(tCtx(), "Matrix", "Pick a button", flags), set->ids[b]); + TEST_EQ(s.calls, 1); + TEST_STR_EQ(s.title, "Matrix"); + } + + // Enter activates the first (default) button. + scriptInit(&s, NULL); + s.pressEnter = true; + TEST_EQ(dvxMessageBox(tCtx(), "Matrix", "Enter", flags), set->ids[0]); + + // Escape activates the last button (OK / Cancel / No). + scriptInit(&s, NULL); + s.pressEsc = true; + TEST_EQ(dvxMessageBox(tCtx(), "Matrix", "Escape", flags), set->ids[set->count - 1]); + + // The close gadget always reports cancel. + scriptInit(&s, NULL); + s.closeGadget = true; + TEST_EQ(dvxMessageBox(tCtx(), "Matrix", "Close", flags), ID_CANCEL); + } + } + + // Unknown button nibble falls back to a lone OK. + scriptInit(&s, "OK"); + TEST_EQ(dvxMessageBox(tCtx(), "Matrix", "Odd", MB_BUTTON_MASK), ID_OK); + + tPump(1); + TEST_PTR_EQ(tFindWindow("Matrix"), NULL); + TEST_EQ(tCtx()->stack.count, 0); + tShutdown(); +} + + +static void caseModality(void) { + tCase("modal input gate and focus return"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + OtherWindowT o = otherWindow("Other"); + TSpyT spy; + ScriptT s; + + tSpyInit(&spy); + tSpyWidget(&spy, o.btn, "onClick"); + tSpyWindow(&spy, o.win, "onFocus"); + tSpyWindow(&spy, o.win, "onBlur"); + TEST_TRUE(o.win->focused); + + scriptInit(&s, "OK"); + s.modalSeen = o.win; // script pokes this window while the modal is up + TEST_EQ(dvxMessageBox(tCtx(), "Modal", "Wait", MB_OK), ID_OK); + + // Nothing reached the other window: no click, no typed text, no raise. + TEST_EQ(tSpyCount(&spy, "onClick"), 0); + TEST_STR_EQ(wgtGetText(o.input), ""); + TEST_EQ(s.capturedInt, 1); // modal stayed on top after the clicks + TEST_EQ(s.capturedInt2, 1); // modal stayed focused after the clicks + + // Focus came back to the previous window once the dialog closed. + tPump(1); + TEST_TRUE(o.win->focused); + TEST_EQ(tSpyCount(&spy, "onBlur"), 1); + TEST_EQ(tSpyCount(&spy, "onFocus"), 1); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + + // With the dialog gone, the same clicks work again. + int32_t sx = 0; + int32_t sy = 0; + + tWidgetCenter(o.btn, &sx, &sy); + tClick(sx, sy); + TEST_EQ(tSpyCount(&spy, "onClick"), 1); + + tSpyFree(&spy); + tShutdown(); +} + + +static void caseNested(void) { + tCase("nested dialog restores the previous modal"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + ScriptT s; + char buf[TEXT_MAX_LEN]; + + scriptInit(&s, "OK"); + tDialogScript(nestedScript, &s); + TEST_TRUE(dvxInputBox(tCtx(), "Outer", "Name?", "outer", buf, sizeof(buf))); + TEST_STR_EQ(buf, "outer"); + TEST_EQ(s.nestedResult, ID_YES); + TEST_PTR_EQ(s.prevModalAfterNested, s.modalSeen); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + tPump(1); + TEST_PTR_EQ(tFindWindow("Outer"), NULL); + TEST_PTR_EQ(tFindWindow("Inner"), NULL); + tShutdown(); +} + + +static void casePromptSave(void) { + tCase("prompt save"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + ScriptT s; + + scriptInit(&s, "Yes"); + TEST_EQ(dvxPromptSave(tCtx(), "Doc"), DVX_SAVE_YES); + TEST_STR_EQ(s.title, "Doc"); + scriptInit(&s, "No"); + TEST_EQ(dvxPromptSave(tCtx(), "Doc"), DVX_SAVE_NO); + scriptInit(&s, "Cancel"); + TEST_EQ(dvxPromptSave(tCtx(), NULL), DVX_SAVE_CANCEL); + TEST_STR_EQ(s.title, "Save"); + scriptInit(&s, NULL); + s.pressEsc = true; + TEST_EQ(dvxPromptSave(tCtx(), "Doc"), DVX_SAVE_CANCEL); + scriptInit(&s, NULL); + s.pressEnter = true; + TEST_EQ(dvxPromptSave(tCtx(), "Doc"), DVX_SAVE_YES); + scriptInit(&s, NULL); + s.closeGadget = true; + TEST_EQ(dvxPromptSave(tCtx(), "Doc"), DVX_SAVE_CANCEL); + tShutdown(); +} + + +static void caseWrapping(void) { + tCase("long message wrapping"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + ScriptT s; + int32_t lineH = tCtx()->font.charHeight; + int32_t baseH = 0; + + // One line: contentH = lineH + 2*pad + button + 3*pad + scriptInit(&s, "OK"); + dvxMessageBox(tCtx(), "Wrap", "short", MB_OK); + baseH = s.contentH; + TEST_EQ(baseH, lineH + MSG_PADDING * 2 + BUTTON_HEIGHT + MSG_PADDING * 3); + + // 30 + 30 + 10 characters with spaces: greedy wrap at 38 columns -> 3 lines. + scriptInit(&s, "OK"); + dvxMessageBox(tCtx(), "Wrap", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccc", MB_OK); + TEST_EQ((s.contentH - baseH) / lineH + 1, 3); + + // Explicit newlines count as lines. + scriptInit(&s, "OK"); + dvxMessageBox(tCtx(), "Wrap", "one\ntwo\nthree\nfour", MB_OK); + TEST_EQ((s.contentH - baseH) / lineH + 1, 4); + + // With an icon the message area is at least the icon area tall. + scriptInit(&s, "OK"); + dvxMessageBox(tCtx(), "Wrap", "short", MB_OK | MB_ICONWARNING); + TEST_TRUE(s.contentH > baseH); + + // A 93-character word is broken at the 38-column limit (3 lines) + // and never widens the box. + scriptInit(&s, "OK"); + dvxMessageBox(tCtx(), "Wrap", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", MB_OK); + TEST_EQ((s.contentH - baseH) / lineH + 1, 3); + TEST_EQ(s.winW, MSG_MAX_WIDTH_FRAME); + + tShutdown(); +} + + +// ============================================================ +// Helpers +// ============================================================ + +static WidgetT *childAt(WidgetT *parent, int32_t idx) { + WidgetT *c = parent ? parent->firstChild : NULL; + + while (c && idx > 0) { + c = c->nextSibling; + idx--; + } + + return c; +} + + +// Find the close gadget by scanning the title bar with the WM hit test. +// The gadget is Motif-style: one click opens the system menu, a +// double-click closes the window. +static void clickCloseGadget(WindowT *win) { + int32_t y = win->y + CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT / 2; + + for (int32_t x = win->x; x < win->x + win->w; x++) { + int32_t part = HIT_NONE; + int32_t idx = wmHitTest(&tCtx()->stack, x, y, &part); + + if (idx >= 0 && tCtx()->stack.windows[idx] == win && part == HIT_CLOSE) { + tAdvanceClock(SINGLE_CLICK_GAP_MS); + tClick(x, y); + tAdvanceClock(DBLCLICK_GAP_MS); + tClick(x, y); + return; + } + } + + TEST_TRUE(!"close gadget not found"); +} + + +// Click (or double-click, with a clock gap so the second press counts) +// on the given visible row of a list box. +static void clickListRow(WidgetT *lb, int32_t row, bool dbl) { + int32_t cx = 0; + int32_t cy = 0; + + tWidgetCenter(lb, &cx, &cy); + + int32_t x = cx - lb->w / 4; + int32_t y = cy - lb->h / 2 + LISTBOX_BORDER + row * tCtx()->font.charHeight + tCtx()->font.charHeight / 2; + + tAdvanceClock(SINGLE_CLICK_GAP_MS); + tClick(x, y); + + if (dbl) { + tAdvanceClock(DBLCLICK_GAP_MS); + tClick(x, y); + } +} + + +static void clickSpinnerArrow(WidgetT *sp, bool up) { + int32_t cx = 0; + int32_t cy = 0; + + tWidgetCenter(sp, &cx, &cy); + + int32_t x = cx + sp->w / 2 - SPINNER_BORDER - SPINNER_BTN_W / 2; + int32_t y = up ? cy - sp->h / 4 : cy + sp->h / 4; + + tAdvanceClock(SINGLE_CLICK_GAP_MS); + tClick(x, y); +} + + +// File dialog script. Layout: pathRow(label,input), list, [filterRow +// (label,dropdown)], nameRow(label,input), buttons. Nested confirmation +// boxes (different title) are answered with nestedButton. +static void fileScript(AppContextT *ctx, WindowT *modal, void *userCtx) { + FileScriptT *f = (FileScriptT *)userCtx; + + (void)ctx; + + if (!scriptEnter(modal)) { + return; + } + + bool outer = strcmp(modal->title, "Open It") == 0 || strcmp(modal->title, "Save It") == 0 || strcmp(modal->title, "Open") == 0 || strcmp(modal->title, "Save As") == 0; + + if (!outer) { + f->nestedSeen = true; + snprintf(f->nestedTitle, sizeof(f->nestedTitle), "%s", modal->title); + TEST_TRUE(tDialogClickButton(modal, f->nestedButton ? f->nestedButton : "OK")); + scriptLeave(); + return; + } + + WidgetT *root = modal->widgetRoot; + WidgetT *pathInput = childAt(childAt(root, 0), 1); + WidgetT *list = childAt(root, 1); + bool hasFilter = childAt(root, 4) != NULL; + WidgetT *filterDd = hasFilter ? childAt(childAt(root, 2), 1) : NULL; + WidgetT *nameInput = childAt(childAt(root, hasFilter ? 3 : 2), 1); + WidgetT *okBtn = childAt(childAt(root, hasFilter ? 4 : 3), 0); + int32_t sx = 0; + int32_t sy = 0; + + TEST_TRUE(pathInput && list && nameInput && okBtn); + + if (!pathInput || !list || !nameInput || !okBtn) { + scriptLeave(); + return; + } + + // Step 1: the dialog survived the action. The modal loop only + // notices 'done' after this frame, so the state is captured on the + // NEXT idle call rather than right after the action. + if (f->acted) { + f->stillModalAfter = true; + f->itemsAfter = dvxListBoxApi()->getItemCount(list); + snprintf(f->pathAfter, sizeof(f->pathAfter), "%s", wgtGetText(pathInput)); + tDialogClickButton(modal, "Cancel"); + scriptLeave(); + return; + } + + f->acted = true; + f->itemsBefore = dvxListBoxApi()->getItemCount(list); + + switch (f->op) { + case FileOpCancelE: + snprintf(f->pathAfter, sizeof(f->pathAfter), "%s", wgtGetText(pathInput)); + tDialogClickButton(modal, "Cancel"); + break; + + case FileOpEscE: + tKeyEsc(); + break; + + case FileOpCloseE: + clickCloseGadget(modal); + break; + + case FileOpDblClickSubE: + clickListRow(list, FIXTURE_IDX_SUB, true); + break; + + case FileOpTypePathE: + tWidgetCenter(pathInput, &sx, &sy); + tClick(sx, sy); + wgtSetText(pathInput, ""); + tType(f->text); + tKeyEnter(); + break; + + case FileOpFilterNextE: + wgtSetFocused(filterDd); + tKey(KEY_SCAN_DOWN, 0, 0); + break; + + case FileOpClickFileOpenE: + fileSelectRow(list, FIXTURE_IDX_ATXT); + snprintf(f->nameAfter, sizeof(f->nameAfter), "%s", wgtGetText(nameInput)); + tWidgetCenter(okBtn, &sx, &sy); + tClick(sx, sy); + break; + + case FileOpDblClickFileE: + clickListRow(list, FIXTURE_IDX_ATXT, true); + break; + + case FileOpTypeNameE: + tWidgetCenter(nameInput, &sx, &sy); + tClick(sx, sy); + tType(f->text); + tWidgetCenter(okBtn, &sx, &sy); + tClick(sx, sy); + break; + + case FileOpEnterInNameE: + tWidgetCenter(nameInput, &sx, &sy); + tClick(sx, sy); + tType(f->text); + tKeyEnter(); + break; + + case FileOpSelectDirOpenE: + fileSelectRow(list, FIXTURE_IDX_SUB); + TEST_STR_EQ(wgtGetText(nameInput), ""); + tWidgetCenter(okBtn, &sx, &sy); + tClick(sx, sy); + break; + } + + scriptLeave(); +} + + +// Single-click a row with the double-click window expired first. +static void fileSelectRow(WidgetT *lb, int32_t row) { + clickListRow(lb, row, false); + TEST_EQ(wgtListBoxGetSelected(lb), row); +} + + +// Fixture: dir/{a.txt, b.txt, c.bmp, sub/{d.txt, deep/}} +static void fixtureTree(char *dir, int32_t dirSize) { + char path[DVX_MAX_PATH * 2]; + + snprintf(dir, dirSize, "%s/tree", tTempDir()); + mkdir(dir, 0700); + snprintf(path, sizeof(path), "%s/sub", dir); + mkdir(path, 0700); + snprintf(path, sizeof(path), "%s/sub/deep", dir); + mkdir(path, 0700); + snprintf(path, sizeof(path), "%s/a.txt", dir); + tWriteFile(path, "a", 1); + snprintf(path, sizeof(path), "%s/b.txt", dir); + tWriteFile(path, "b", 1); + snprintf(path, sizeof(path), "%s/c.bmp", dir); + tWriteFile(path, "c", 1); + snprintf(path, sizeof(path), "%s/sub/d.txt", dir); + tWriteFile(path, "d", 1); +} + + +// Outer input box: open a nested message box from the idle script, +// answer it, and record which modal was restored afterwards. +static void nestedScript(AppContextT *ctx, WindowT *modal, void *userCtx) { + ScriptT *s = (ScriptT *)userCtx; + + if (!scriptEnter(modal)) { + return; + } + + if (strcmp(modal->title, "Inner") == 0) { + TEST_PTR_EQ(ctx->modalWindow, modal); + TEST_TRUE(modal->focused); + tDialogClickButton(modal, "Yes"); + scriptLeave(); + return; + } + + scriptRecord(s, modal); + + if (s->calls == 1) { + s->nestedResult = dvxMessageBox(ctx, "Inner", "Nested?", MB_YESNO | MB_ICONQUESTION); + s->prevModalAfterNested = ctx->modalWindow; + TEST_TRUE(modal->focused); + tDialogClickButton(modal, s->button); + } + + scriptLeave(); +} + + +static OtherWindowT otherWindow(const char *title) { + OtherWindowT o; + + memset(&o, 0, sizeof(o)); + o.win = dvxCreateWindow(tCtx(), title, OTHER_WIN_X, OTHER_WIN_Y, OTHER_WIN_W, OTHER_WIN_H, false); + + WidgetT *root = wgtInitWindow(tCtx(), o.win); + WidgetT *box = wgtVBox(root); + + o.btn = wgtButton(box, "Press"); + o.input = wgtTextInput(box, TEXT_MAX_LEN); + wgtSetName(o.btn, "press"); + wgtSetName(o.input, "input"); + wgtInvalidate(root); + tPump(SETTLE_FRAMES); + return o; +} + + +// Re-entrancy guard keyed by modal window: the same script may be +// invoked again for the same modal from a nested pump; refuse that, but +// let it run for a different (nested) modal. +static bool scriptEnter(WindowT *modal) { + for (int32_t i = 0; i < sBusyDepth; i++) { + if (sBusy[i] == modal) { + return false; + } + } + + if (sBusyDepth >= BUSY_STACK_MAX) { + return false; + } + + sBusy[sBusyDepth++] = modal; + return true; +} + + +static void scriptInit(ScriptT *s, const char *button) { + memset(s, 0, sizeof(*s)); + s->button = button; + tDialogScript(uniformScript, s); +} + + +static void scriptLeave(void) { + if (sBusyDepth > 0) { + sBusyDepth--; + } +} + + +static void scriptRecord(ScriptT *s, WindowT *modal) { + s->calls++; + snprintf(s->title, sizeof(s->title), "%s", modal->title); + s->winX = modal->x; + s->winY = modal->y; + s->winW = modal->w; + s->winH = modal->h; + s->contentH = modal->contentH; + s->modalSeen = modal; +} + + +static void spinInit(ScriptT *s, const char *button) { + scriptInit(s, button); + tDialogScript(spinScript, s); +} + + +// Int input box: layout label, spinner, buttons. capturedInt = arrow +// clicks (+up / -down), capturedInt2 = keyboard Up presses, typeText = +// replace the text after End + backspaces. +static void spinScript(AppContextT *ctx, WindowT *modal, void *userCtx) { + ScriptT *s = (ScriptT *)userCtx; + + (void)ctx; + + if (!scriptEnter(modal)) { + return; + } + + scriptRecord(s, modal); + + if (s->calls > 1) { + scriptLeave(); + return; + } + + WidgetT *spinner = childAt(modal->widgetRoot, 1); + + TEST_PTR_EQ(wgtGetFocused(), spinner); + + for (int32_t i = 0; i < s->capturedInt; i++) { + clickSpinnerArrow(spinner, true); + } + + for (int32_t i = 0; i > s->capturedInt; i--) { + clickSpinnerArrow(spinner, false); + } + + for (int32_t i = 0; i < s->capturedInt2; i++) { + tKey(KEY_SCAN_UP, 0, 0); + } + + if (s->typeText) { + wgtSetFocused(spinner); + tKey(KEY_END & ~KEY_EXT_FLAG, 0, 0); + + for (int32_t i = 0; i < BACKSPACE_CLEAR_COUNT; i++) { + tKeyBackspace(); + } + + tType(s->typeText); + } + + if (s->pressEnter) { + tKeyEnter(); + } else if (s->pressEsc) { + tKeyEsc(); + } else if (s->closeGadget) { + clickCloseGadget(modal); + } else if (s->button) { + TEST_TRUE(tDialogClickButton(modal, s->button)); + } + + scriptLeave(); +} + + +// Generic script for message/input/choice boxes and geometry checks. +// Nested dialogs (a different title than the first one seen) get +// nestedButton. For the choice dialog capturedInt2 > 0 presses Down +// that many times, < 0 double-clicks row (-capturedInt2 - 1). +static void uniformScript(AppContextT *ctx, WindowT *modal, void *userCtx) { + ScriptT *s = (ScriptT *)userCtx; + + if (!scriptEnter(modal)) { + return; + } + + if (s->calls > 0 && s->modalSeen != modal && s->nestedButton) { + tDialogClickButton(modal, s->nestedButton); + scriptLeave(); + return; + } + + if (s->calls > 0 && s->modalSeen == modal) { + // Second visit to the same dialog (e.g. OK refused): fall back. + s->calls++; + + if (s->nestedButton) { + tDialogClickButton(modal, s->nestedButton); + } + + scriptLeave(); + return; + } + + // caseModality passes the other window in modalSeen before the + // first call; poke it before recording the modal. + WindowT *other = s->modalSeen; + + scriptRecord(s, modal); + + if (other) { + int32_t sx = 0; + int32_t sy = 0; + + tWidgetCenter(tFind(other, "press"), &sx, &sy); + tClick(sx, sy); + tWidgetCenter(tFind(other, "input"), &sx, &sy); + tClick(sx, sy); + tType("nope"); + tClick(other->x + other->w / 2, other->y + CHROME_BORDER_WIDTH + CHROME_TITLE_HEIGHT / 2); + tAltTab(); + s->capturedInt = (ctx->stack.windows[ctx->stack.count - 1] == modal) ? 1 : 0; + s->capturedInt2 = modal->focused ? 1 : 0; + } + + if (s->goldenName) { + // Park the cursor off the dialog so the crop holds only chrome, + // icon, text and buttons. + tMouseMove(0, 0); + tGoldenRect(s->goldenName, modal->x, modal->y, modal->w, modal->h); + } + + // Choice dialog: record the highlighted row and optionally move it. + WidgetT *focus = wgtGetFocused(); + + if (focus && strcmp(modal->title, "Pick") == 0) { + s->capturedInt = wgtListBoxGetSelected(focus); + + for (int32_t i = 0; i < s->capturedInt2; i++) { + tKey(KEY_SCAN_DOWN, 0, 0); + } + + if (s->capturedInt2 < 0) { + clickListRow(focus, -s->capturedInt2 - 1, true); + } + } + + // Input box: the text input owns focus; capture its default text. + if (focus && strcmp(modal->title, "Ask") == 0) { + snprintf(s->captured, sizeof(s->captured), "%s", wgtGetText(focus)); + } + + if (s->typeText) { + tType(s->typeText); + } + + if (s->abandon) { + ctx->running = false; + } else if (s->pressEnter) { + tKeyEnter(); + } else if (s->pressEsc) { + tKeyEsc(); + } else if (s->closeGadget) { + clickCloseGadget(modal); + } else if (s->button) { + bool found = tDialogClickButton(modal, s->button); + + if (!found && s->nestedButton) { + tDialogClickButton(modal, s->nestedButton); + } + } + + scriptLeave(); +} + + +int main(void) { + platformSetLogPath("dialogsTest.log"); + tSuiteBegin("T4 dialogs"); + + tSetGoldenDir(DIALOGS_GOLDEN_DIR); + + int32_t loaded = tProcessInit(DVXTEST_WIDGET_DIR); + + printf("widgets loaded: %ld\n", (long)loaded); + TEST_TRUE(loaded > 0); + + caseMessageBoxMatrix(); + caseErrorInfoBox(); + caseInputBox(); + caseIntInputBox(); + caseChoice(); + casePromptSave(); + caseFileDialog(); + caseModality(); + caseNested(); + caseAbandon(); + caseGeometry(SCREEN_W, SCREEN_H); + caseGeometry(SCREEN_W_LARGE, SCREEN_H_LARGE); + caseWrapping(); + caseGolden(); + + return tSuiteEnd(); +} diff --git a/src/test/dialogs/golden/msgBoxInfo32.png b/src/test/dialogs/golden/msgBoxInfo32.png new file mode 100644 index 0000000..014120b --- /dev/null +++ b/src/test/dialogs/golden/msgBoxInfo32.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae77c6aef7e98be6b1f9255b21edb0b41056dd8c74a464b1f4398693f623ed0f +size 2515 diff --git a/src/test/dvxtest/Makefile b/src/test/dvxtest/Makefile new file mode 100644 index 0000000..94aad26 --- /dev/null +++ b/src/test/dvxtest/Makefile @@ -0,0 +1,49 @@ +# 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. + +# dvxtest runner library: builds libdvxtest.a, runs the self test and the +# static-state grep gate. SAN=1 (default) for ASan/UBSan; SAN=0 plain. + +include dvxtest.mk + +.DEFAULT_GOAL := all + +SELFTEST = $(DVXTEST_BINDIR)/selfTest + +.PHONY: all lib run check-statics clean + +all: run check-statics + +lib: dvxtest-libs + +$(SELFTEST): selfTest.c dvxtest.h lib + @mkdir -p $(DVXTEST_BINDIR) + $(HOSTCC) $(DVXTEST_CFLAGS) -o $@ selfTest.c $(DVXTEST_LINK) + +run: $(SELFTEST) + cd $(DVXTEST_BINDIR) && ASAN_OPTIONS=detect_leaks=1 ./selfTest + +check-statics: + ./checkStatics.sh + +clean: dvxtest-clean + rm -f $(SELFTEST) diff --git a/src/test/dvxtest/README.md b/src/test/dvxtest/README.md new file mode 100644 index 0000000..4ee660c --- /dev/null +++ b/src/test/dvxtest/README.md @@ -0,0 +1,103 @@ +# dvxtest -- host-side test runner for the DVX GUI stack + +Native Linux library that drives the real libdvx/widget code on the +offscreen host platform backend (`platform/dvxPlatformHost.c`). Input is +queued, the clock is fake, and every helper pumps exactly the frames it +needs, so suites are deterministic and run under ASan/UBSan. + +## Build and run + + make -C src/test/dvxtest # lib + selfTest + static-state gate (SAN=1) + make -C src/test/dvxtest SAN=0 # plain build + UPDATE_GOLDENS=1 make -C src/test/dvxtest run # regenerate golden PNGs + +Outputs land in `obj/host/test/` (`libdvxtest.a`, `selfTest`). + +## Writing a suite + + include ../dvxtest/dvxtest.mk + mySuite: mySuite.c $(DVXTEST_LIB) + $(HOSTCC) $(DVXTEST_CFLAGS) -o $@ mySuite.c $(DVXTEST_LINK) + +`dvxtest.mk` provides `DVXTEST_CFLAGS`, `DVXTEST_LINK`, `DVXTEST_LIB` (with +a rule that also builds `obj/host/libdvx.a` and the widget `.so` files), +and the compile-time defines `DVXTEST_WIDGET_DIR` and `DVXTEST_GOLDEN_DIR` +(override the latter per suite with `DVXTEST_GOLDEN_DIR=...`). The +archive also carries `textHelp.c` and `listHelp.c`, which the text and +list widgets resolve from the executable at load time. + + int main(void) { + tSuiteBegin("my suite"); + tProcessInit(DVXTEST_WIDGET_DIR); // once per process + tCase("something"); + tInit(640, 480, 32); + ... build windows, drive input, assert ... + tShutdown(); + return tSuiteEnd(); + } + +## API summary + +Assertions (record pass/fail, print `file:line` plus expected/actual, +never abort): `TEST_TRUE`, `TEST_EQ`, `TEST_STR_EQ`, `TEST_PTR_EQ`. + +Lifecycle: `tSuiteBegin(name)`, `tCase(name)`, `tSuiteEnd()` (returns the +exit code), `tProcessInit(widgetDir)` (dlopen + wgtRegister every `.so` +once), `tInit(w, h, bpp)` (memset context, dvxInit, clock = 0), +`tShutdown()` (dvxShutdown, then `wgtTestReset`, `dvxDialogTestReset`, +`basFormRtTestReset` when linked), `tCtx()`. + +Pumping: `tPump(n)`, `tPumpUntil(pred, ctx, maxFrames)`, +`tAdvanceClock(ms)` (fake clock + one frame). Pump one extra frame after +any destroy before asserting -- deferred destroys flush when the dispatch +depth reaches zero. + +Queue input (full chrome / hit-test path, screen coordinates, one sample +per frame): `tMouseMove`, `tMouseDown`, `tMouseUp`, `tClick`, `tDblClick`, +`tDrag`, `tWheel`, `tKey(scan, ascii, mods)`, `tKeyEnter`, `tKeyTab`, +`tKeyShiftTab`, `tKeyEsc`, `tKeySpace`, `tKeyBackspace`, `tAltTab`, +`tAltF4`, `tFKey(n)`, `tType("text")`. + +Direct dispatch (content-relative, no chrome): `tWidgetClick(w)` via +`widgetOnMouse`, `tWidgetKey(win, key, mods)` via `widgetOnKey` (Tab +navigation and accelerators live in the app layer and are not reached). + +Locators: `tFindWindow(title)`, `tFind(win, name)` (wgtSetName), +`tFindByText(root, caption)` ('&' ignored), `tWidgetCenter(w, &sx, &sy)`. + +Spies: `TSpyT` + `tSpyInit/tSpyFree/tSpyClear`, `tSpyWidget(spy, w, +"onClick")`, `tSpyWindow(spy, win, "onFocus")`, `tSpyCount(spy, name)`, +`tSpyLog(spy)` ("label.slot,..."), `tSpyOrder(spy, csv)`. Entries keep +the slot, target, up to three integer args and the `sWidgetGen` at the +time. The previous callback still runs after logging; note that spying a +slot that was NULL makes it non-NULL (e.g. a window `onClose` spy +replaces the default destroy). Hooks die with their widget. + +Dialogs: `tDialogScript(fn, ctx)` installs `fn` as the idle callback so a +blocking `dvxMessageBox` etc. can be driven from inside its nested loop; +`fn` receives `ctx->modalWindow`. `tDialogClickButton(modal, "OK")`. + +Goldens: `tGoldenRect(name, x, y, w, h)` compares the flushed framebuffer +region with `golden/.png`; `tWindowGolden(name, win)` compares the +window content buffer. Mismatches write `golden/.actual.png` and +print an FNV hash; `UPDATE_GOLDENS=1` rewrites the golden. + +Fixtures: `tTempDir()` (removed at `tShutdown`/`tSuiteEnd`), +`tWriteFile(path, data, len)`. + +## Static-state gate + +`checkStatics.sh` greps every file-scope variable in the libdvx widget, +app and dialog sources and fails unless the name appears in one of the +`*TestReset` hooks or in `staticsAllowlist.txt`. Add a new module-level +pointer to `wgtTestReset` (or a per-file helper it calls) or the allowlist. + +## Reset hooks + +`wgtTestReset` (widgetCore.c, declared in dvxWgtP.h) clears focus/drag/ +popup pointers, click state, poll list, destroy subscribers, clipboard, +and calls `widgetEventTestReset`, `widgetOpsTestReset`, `dvxAppTestReset` +and the timer widget's `testReset` API. `dvxDialogTestReset` clears the +five dialog statics. `textHelpTestReset` (texthelp) and +`basFormRtTestReset` (formrt) are called through weak references when the +suite links them. diff --git a/src/test/dvxtest/checkStatics.sh b/src/test/dvxtest/checkStatics.sh new file mode 100755 index 0000000..be7d5d2 --- /dev/null +++ b/src/test/dvxtest/checkStatics.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# checkStatics.sh -- every file-scope variable in the libdvx widget/app/ +# dialog sources must be handled by a test reset hook or be listed in +# staticsAllowlist.txt (process-lifetime tables). Guards the host test +# runner against a new static that dangles across tInit cycles. +# +# Candidates: column-0 definitions ending in ';' (optionally 'static'), +# including function-pointer variables, excluding prototypes, typedefs, +# externs and const data with no pointer inside. +# +# Exit status is non-zero when an unhandled variable is found. + +set -u + +here=$(cd "$(dirname "$0")" && pwd) +libdvx=$(cd "$here/../../libs/kpunch/libdvx" && pwd) +allow="$here/staticsAllowlist.txt" +failFlag="$here/.checkStatics.fail" + +files="$libdvx/widgetCore.c $libdvx/widgetEvent.c $libdvx/widgetOps.c $libdvx/widgetClass.c $libdvx/widgetLayout.c $libdvx/widgetScrollbar.c $libdvx/dvxApp.c $libdvx/dvxDialog.c" + +# Reset hook bodies: everything between each hook's opening line and the +# first line that is exactly "}". +resetBody=$(for f in $files; do + awk '/^void (wgtTestReset|widgetEventTestReset|widgetOpsTestReset|dvxAppTestReset|dvxDialogTestReset)\(void\) \{/ { on = 1 } on { print } on && /^\}/ { on = 0 }' "$f" +done) + +rm -f "$failFlag" + +for f in $files; do + base=$(basename "$f") + + grep -nE '^(static )?[A-Za-z_][A-Za-z0-9_ ]*[ *](\(\*+[A-Za-z_][A-Za-z0-9_]*\)\([^;]*\)|\**[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])*)( *=[^;]*)?;' "$f" \ + | grep -vE '^[0-9]+:(typedef|extern|return) ' \ + | grep -vE '^[0-9]+:(static )?const [^*]*;' \ + | grep -vE '^[0-9]+:static const .*\*const ' \ + | while IFS=: read -r line text; do + case "$text" in + *"(*"*) + name=$(printf '%s' "$text" | sed -E 's/.*\(\*+([A-Za-z_][A-Za-z0-9_]*)\).*/\1/') + ;; + *) + name=$(printf '%s' "$text" | sed -E 's/^(static )?[A-Za-z_][A-Za-z0-9_ ]*[ *]\**([A-Za-z_][A-Za-z0-9_]*).*/\2/') + ;; + esac + + if grep -qE "^$name\$" "$allow"; then + continue + fi + + if printf '%s' "$resetBody" | grep -qE "\b$name\b"; then + continue + fi + + printf 'UNHANDLED %s:%s %s\n' "$base" "$line" "$name" + echo fail > "$failFlag" + done +done + +if [ -f "$failFlag" ]; then + rm -f "$failFlag" + echo "checkStatics: FAIL -- add to a *TestReset hook or to staticsAllowlist.txt" + exit 1 +fi + +echo "checkStatics: OK" +exit 0 diff --git a/src/test/dvxtest/dvxtest.c b/src/test/dvxtest/dvxtest.c new file mode 100644 index 0000000..48e5d3f --- /dev/null +++ b/src/test/dvxtest/dvxtest.c @@ -0,0 +1,1393 @@ +// 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. + +// dvxtest.c -- host-side test runner library (see dvxtest.h) + +#include "dvxtest.h" +#include "dvxWgtP.h" +#include "dvxVideo.h" +#include "stb_ds_wrap.h" + +#include +#include +#include +#include +#include +#include +#include + +// ============================================================ +// Constants +// ============================================================ + +#define WIDGET_SO_EXT ".so" +#define GOLDEN_PNG_EXT ".png" +#define GOLDEN_ACTUAL_EXT ".actual.png" +#define UPDATE_GOLDENS_ENV "UPDATE_GOLDENS" +#define TEMP_DIR_TEMPLATE "dvxtest.XXXXXX" +#define TEMP_DIR_ENV "TMPDIR" +#define TEMP_DIR_DEFAULT "/tmp" +#define FTW_MAX_OPEN_FDS 16 + +// Frames pumped so a queued sample or a layout change fully settles +// (input frame + paint + composite). +#define SETTLE_FRAMES 2 + +// Key scancodes not already named in dvxTypes.h. +#define SCAN_ENTER 0x1C +#define SCAN_BACKSPACE 0x0E +#define SCAN_F11 0x57 +#define SCAN_F12 0x58 +#define FKEY_F10 10 +#define FKEY_F11 11 +#define FKEY_F12 12 + +// FNV-1a parameters for the golden hash printed on mismatch. +#define FNV_OFFSET 2166136261u +#define FNV_PRIME 16777619u + +// Spy target kinds. +#define SPY_KIND_WIDGET 0 +#define SPY_KIND_WINDOW 1 + +#define SPY_LOG_BUF_SIZE 4096 + +#ifndef DVXTEST_GOLDEN_DIR +#define DVXTEST_GOLDEN_DIR "golden" +#endif + +static const char *sGoldenDir = DVXTEST_GOLDEN_DIR; + + +// ============================================================ +// Types +// ============================================================ + +typedef void (*WgtRegisterFnT)(void); + +// One live spy attachment: which slot on which target, the spy that +// receives the entries and the callback that ran there before. +typedef struct { + int32_t kind; + int32_t slot; + void *target; + int32_t targetId; + TSpyT *spy; + void *prevFn; +} SpyHookT; + +// Widget slot indices (order matches sWidgetSlotNames). +typedef enum { + WSlotOnClickE = 0, + WSlotOnDblClickE, + WSlotOnChangeE, + WSlotOnFocusE, + WSlotOnBlurE, + WSlotOnKeyPressE, + WSlotOnKeyDownE, + WSlotOnKeyUpE, + WSlotOnMouseDownE, + WSlotOnMouseUpE, + WSlotOnMouseMoveE, + WSlotOnScrollE, + WSlotOnValidateE, + WSlotCountE +} WidgetSlotE; + +// Window slot indices (order matches sWindowSlotNames). +typedef enum { + NSlotOnPaintE = 0, + NSlotOnKeyE, + NSlotOnKeyUpE, + NSlotOnMouseE, + NSlotOnResizeE, + NSlotOnCloseE, + NSlotOnMenuE, + NSlotOnScrollE, + NSlotOnFocusE, + NSlotOnBlurE, + NSlotCountE +} WindowSlotE; + + +// ============================================================ +// Prototypes +// ============================================================ + +static void caseFail(const char *file, int32_t line, const char *expr); +static void dialogIdle(void *userCtx); +static int32_t fnvHashRegion(const DisplayT *d, const uint8_t *buf, int32_t pitch, int32_t w, int32_t h, uint32_t *outHash); +static bool goldenCompare(const char *name, const uint8_t *buf, int32_t pitch, int32_t w, int32_t h); +static void goldenPath(char *out, int32_t outSize, const char *name, const char *ext); +static void keyPress(int32_t scancode, int32_t ascii, int32_t mods); +static bool matchCaption(const char *text, const char *caption); +static void mousePush(int32_t x, int32_t y, int32_t buttons); +static uint32_t readPixel(const uint8_t *p, int32_t bpp); +static void removeTempDir(void); +static int32_t removeTempEntry(const char *path, const struct stat *st, int typeflag, struct FTW *ftw); +static int32_t scanForChar(char c, bool *needShift); +static void spyAppend(TSpyT *spy, SpyHookT *hook, const char *label, int32_t argCount, int32_t a0, int32_t a1, int32_t a2); +static SpyHookT *spyFindHook(int32_t kind, int32_t slot, const void *target); +static void spyForgetTarget(const void *target); +static void spyOnWidgetDestroy(WidgetT *w); +static void spyReleaseAll(void); +static int32_t spySlotIndex(const char *const *names, int32_t count, const char *slot); +static void widgetLabel(const WidgetT *w, char *out, int32_t outSize); +static void windowLabel(const WindowT *win, char *out, int32_t outSize); + +// Spy trampolines -- widget slots. +static void trWOnBlur(WidgetT *w); +static void trWOnChange(WidgetT *w); +static void trWOnClick(WidgetT *w); +static void trWOnDblClick(WidgetT *w); +static void trWOnFocus(WidgetT *w); +static void trWOnKeyDown(WidgetT *w, int32_t keyCode, int32_t shift); +static void trWOnKeyPress(WidgetT *w, int32_t keyAscii); +static void trWOnKeyUp(WidgetT *w, int32_t keyCode, int32_t shift); +static void trWOnMouseDown(WidgetT *w, int32_t button, int32_t x, int32_t y); +static void trWOnMouseMove(WidgetT *w, int32_t button, int32_t x, int32_t y); +static void trWOnMouseUp(WidgetT *w, int32_t button, int32_t x, int32_t y); +static void trWOnScroll(WidgetT *w, int32_t delta); +static bool trWOnValidate(WidgetT *w); + +// Spy trampolines -- window slots. +static void trNOnBlur(WindowT *win); +static void trNOnClose(WindowT *win); +static void trNOnFocus(WindowT *win); +static void trNOnKey(WindowT *win, int32_t key, int32_t mod); +static void trNOnKeyUp(WindowT *win, int32_t scancode, int32_t mod); +static void trNOnMenu(WindowT *win, int32_t menuId); +static void trNOnMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons); +static void trNOnPaint(WindowT *win, RectT *dirtyArea); +static void trNOnResize(WindowT *win, int32_t newW, int32_t newH); +static void trNOnScroll(WindowT *win, ScrollbarOrientE orient, int32_t value); + +// Optional reset hooks supplied by modules a suite may or may not link. +void basFormRtTestReset(void) __attribute__((weak)); +void textHelpTestReset(void) __attribute__((weak)); + + +// ============================================================ +// Module state +// ============================================================ + +static AppContextT *sCtx = NULL; +static const char *sSuiteName = ""; +static const char *sCaseName = ""; +static int32_t sPassCount = 0; +static int32_t sFailCount = 0; +static bool sProcessReady = false; +static SpyHookT *sHooks = NULL; // stb_ds dynamic array +static TDialogScriptFnT sDialogFn = NULL; +static void *sDialogCtx = NULL; +static char sTempDir[DVX_MAX_PATH]; +static char sSpyLogBuf[SPY_LOG_BUF_SIZE]; + +static const char *const sWidgetSlotNames[WSlotCountE] = { + "onClick", "onDblClick", "onChange", "onFocus", "onBlur", "onKeyPress", "onKeyDown", "onKeyUp", "onMouseDown", "onMouseUp", "onMouseMove", "onScroll", "onValidate" +}; + +static const char *const sWindowSlotNames[NSlotCountE] = { + "onPaint", "onKey", "onKeyUp", "onMouse", "onResize", "onClose", "onMenu", "onScroll", "onFocus", "onBlur" +}; + +static void *const sWidgetTrampolines[WSlotCountE] = { + (void *)trWOnClick, (void *)trWOnDblClick, (void *)trWOnChange, (void *)trWOnFocus, (void *)trWOnBlur, (void *)trWOnKeyPress, (void *)trWOnKeyDown, (void *)trWOnKeyUp, (void *)trWOnMouseDown, (void *)trWOnMouseUp, (void *)trWOnMouseMove, (void *)trWOnScroll, (void *)trWOnValidate +}; + +static void *const sWindowTrampolines[NSlotCountE] = { + (void *)trNOnPaint, (void *)trNOnKey, (void *)trNOnKeyUp, (void *)trNOnMouse, (void *)trNOnResize, (void *)trNOnClose, (void *)trNOnMenu, (void *)trNOnScroll, (void *)trNOnFocus, (void *)trNOnBlur +}; + +// PC set-1 scancode rows for tType. Index in the string + row base. +static const char *const sScanRow1 = "1234567890-="; +static const char *const sScanRow1Sh = "!@#$%^&*()_+"; +static const char *const sScanRow2 = "qwertyuiop[]"; +static const char *const sScanRow2Sh = "QWERTYUIOP{}"; +static const char *const sScanRow3 = "asdfghjkl;'`"; +static const char *const sScanRow3Sh = "ASDFGHJKL:\"~"; +static const char *const sScanRow4 = "\\zxcvbnm,./"; +static const char *const sScanRow4Sh = "|ZXCVBNM<>?"; + +#define SCAN_ROW1_BASE 0x02 +#define SCAN_ROW2_BASE 0x10 +#define SCAN_ROW3_BASE 0x1E +#define SCAN_ROW4_BASE 0x2B + + +// Trampolines: look the hook up, log, then chain to the previous +// callback. Nothing dereferences the target after the chained call +// returns -- the callback may have destroyed it. + +#define SPY_WIDGET_PROLOGUE(slotE, w, argCount, a0, a1, a2) \ + SpyHookT *hook = spyFindHook(SPY_KIND_WIDGET, (slotE), (w)); \ + void *prev = NULL; \ + if (hook) { \ + char label[MAX_WIDGET_NAME]; \ + widgetLabel((w), label, sizeof(label)); \ + spyAppend(hook->spy, hook, label, (argCount), (a0), (a1), (a2)); \ + prev = hook->prevFn; \ + } + +#define SPY_WINDOW_PROLOGUE(slotE, win, argCount, a0, a1, a2) \ + SpyHookT *hook = spyFindHook(SPY_KIND_WINDOW, (slotE), (win)); \ + void *prev = NULL; \ + if (hook) { \ + char label[MAX_TITLE_LEN]; \ + windowLabel((win), label, sizeof(label)); \ + spyAppend(hook->spy, hook, label, (argCount), (a0), (a1), (a2)); \ + prev = hook->prevFn; \ + } + + +// ============================================================ +// Functions +// ============================================================ + +static void caseFail(const char *file, int32_t line, const char *expr) { + sFailCount++; + fprintf(stderr, "FAIL %s:%ld [%s/%s] %s\n", file, (long)line, sSuiteName, sCaseName, expr); +} + + +static void dialogIdle(void *userCtx) { + AppContextT *ctx = (AppContextT *)userCtx; + + if (sDialogFn && ctx->modalWindow) { + sDialogFn(ctx, ctx->modalWindow, sDialogCtx); + } +} + + +static int32_t fnvHashRegion(const DisplayT *d, const uint8_t *buf, int32_t pitch, int32_t w, int32_t h, uint32_t *outHash) { + uint32_t hash = FNV_OFFSET; + int32_t bpp = d->format.bytesPerPixel; + + for (int32_t y = 0; y < h; y++) { + const uint8_t *row = buf + (size_t)y * (size_t)pitch; + + for (int32_t x = 0; x < w; x++) { + uint8_t r; + uint8_t g; + uint8_t b; + + unpackColor(d, readPixel(row + (size_t)x * (size_t)bpp, bpp), &r, &g, &b); + hash = (hash ^ r) * FNV_PRIME; + hash = (hash ^ g) * FNV_PRIME; + hash = (hash ^ b) * FNV_PRIME; + } + } + + *outHash = hash; + return w * h; +} + + +static bool goldenCompare(const char *name, const uint8_t *buf, int32_t pitch, int32_t w, int32_t h) { + const DisplayT *d = &sCtx->display; + char path[DVX_MAX_PATH]; + char expr[DVX_MAX_PATH]; + + goldenPath(path, sizeof(path), name, GOLDEN_PNG_EXT); + snprintf(expr, sizeof(expr), "golden \"%s\"", name); + + const char *update = getenv(UPDATE_GOLDENS_ENV); + + if (update && update[0] == '1') { + bool ok = dvxSaveImage(sCtx, buf, w, h, pitch, path) == 0; + + printf("golden updated: %s\n", path); + return tCheckTrue(__FILE__, __LINE__, expr, ok); + } + + int32_t gw = 0; + int32_t gh = 0; + int32_t gp = 0; + uint8_t *gold = dvxLoadImage(sCtx, path, &gw, &gh, &gp); + bool match = gold != NULL && gw == w && gh == h; + int32_t bpp = d->format.bytesPerPixel; + int32_t badX = -1; + int32_t badY = -1; + + for (int32_t y = 0; match && y < h; y++) { + const uint8_t *row = buf + (size_t)y * (size_t)pitch; + const uint8_t *grow = gold + (size_t)y * (size_t)gp; + + for (int32_t x = 0; x < w; x++) { + uint8_t r0; + uint8_t g0; + uint8_t b0; + uint8_t r1; + uint8_t g1; + uint8_t b1; + + unpackColor(d, readPixel(row + (size_t)x * (size_t)bpp, bpp), &r0, &g0, &b0); + unpackColor(d, readPixel(grow + (size_t)x * (size_t)bpp, bpp), &r1, &g1, &b1); + + if (r0 != r1 || g0 != g1 || b0 != b1) { + match = false; + badX = x; + badY = y; + break; + } + } + } + + if (!match) { + char actualPath[DVX_MAX_PATH]; + uint32_t hash = 0; + + goldenPath(actualPath, sizeof(actualPath), name, GOLDEN_ACTUAL_EXT); + dvxSaveImage(sCtx, buf, w, h, pitch, actualPath); + fnvHashRegion(d, buf, pitch, w, h, &hash); + tCheckTrue(__FILE__, __LINE__, expr, false); + + if (!gold) { + fprintf(stderr, " - golden missing: %s (set %s=1 to create)\n", path, UPDATE_GOLDENS_ENV); + } else if (gw != w || gh != h) { + fprintf(stderr, " - golden size %ldx%ld, actual %ldx%ld\n", (long)gw, (long)gh, (long)w, (long)h); + } else { + fprintf(stderr, " - first mismatch at (%ld,%ld)\n", (long)badX, (long)badY); + } + + fprintf(stderr, " + actual hash %08lx written to %s\n", (unsigned long)hash, actualPath); + } else { + sPassCount++; + } + + dvxFreeImage(gold); + return match; +} + + +static void goldenPath(char *out, int32_t outSize, const char *name, const char *ext) { + snprintf(out, (size_t)outSize, "%s" DVX_PATH_SEP "%s%s", sGoldenDir, name, ext); +} + + +static void keyPress(int32_t scancode, int32_t ascii, int32_t mods) { + hostKeyPush(ascii, scancode, mods); + tPump(1); + hostKeyUpPush(scancode); + hostSetModifiers(0); + tPump(1); +} + + +// Caption match ignoring the '&' accelerator marker in widget text. +static bool matchCaption(const char *text, const char *caption) { + if (!text || !caption) { + return false; + } + + while (*text || *caption) { + if (*text == '&') { + text++; + continue; + } + + if (*text != *caption) { + return false; + } + + text++; + caption++; + } + + return true; +} + + +static void mousePush(int32_t x, int32_t y, int32_t buttons) { + hostMousePush(x, y, buttons); + tPump(1); +} + + +static uint32_t readPixel(const uint8_t *p, int32_t bpp) { + switch (bpp) { + case 1: + return p[0]; + case 2: + return (uint32_t)p[0] | ((uint32_t)p[1] << 8); + default: + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + } +} + + +static void removeTempDir(void) { + if (!sTempDir[0]) { + return; + } + + nftw(sTempDir, removeTempEntry, FTW_MAX_OPEN_FDS, FTW_DEPTH | FTW_PHYS); + sTempDir[0] = '\0'; +} + + +static int32_t removeTempEntry(const char *path, const struct stat *st, int typeflag, struct FTW *ftw) { + (void)st; + (void)typeflag; + (void)ftw; + return remove(path); +} + + +// Map a printable character to its set-1 scancode; sets *needShift for +// upper case and shifted symbols. Returns 0 for unknown characters. +static int32_t scanForChar(char c, bool *needShift) { + const char *const rows[] = { sScanRow1, sScanRow2, sScanRow3, sScanRow4 }; + const char *const shRows[] = { sScanRow1Sh, sScanRow2Sh, sScanRow3Sh, sScanRow4Sh }; + const int32_t bases[] = { SCAN_ROW1_BASE, SCAN_ROW2_BASE, SCAN_ROW3_BASE, SCAN_ROW4_BASE }; + + *needShift = false; + + if (c == ' ') { + return KEY_SCAN_SPACE; + } + + for (int32_t r = 0; r < (int32_t)(sizeof(rows) / sizeof(rows[0])); r++) { + const char *hit = strchr(rows[r], c); + + if (hit) { + return bases[r] + (int32_t)(hit - rows[r]); + } + + hit = strchr(shRows[r], c); + + if (hit) { + *needShift = true; + return bases[r] + (int32_t)(hit - shRows[r]); + } + } + + return 0; +} + + +static void spyAppend(TSpyT *spy, SpyHookT *hook, const char *label, int32_t argCount, int32_t a0, int32_t a1, int32_t a2) { + TSpyEntryT e; + + memset(&e, 0, sizeof(e)); + snprintf(e.slot, sizeof(e.slot), "%s", hook->kind == SPY_KIND_WIDGET ? sWidgetSlotNames[hook->slot] : sWindowSlotNames[hook->slot]); + + if (label[0]) { + snprintf(e.text, sizeof(e.text), "%s.%s", label, e.slot); + } else { + snprintf(e.text, sizeof(e.text), "%s", e.slot); + } + + e.target = hook->target; + e.targetId = hook->targetId; + e.args[0] = a0; + e.args[1] = a1; + e.args[2] = a2; + e.argCount = argCount; + e.widgetGen = sWidgetGen; + arrput(spy->entries, e); +} + + +static SpyHookT *spyFindHook(int32_t kind, int32_t slot, const void *target) { + for (int32_t i = 0; i < (int32_t)arrlen(sHooks); i++) { + SpyHookT *h = &sHooks[i]; + + if (h->kind == kind && h->slot == slot && h->target == target) { + if (kind == SPY_KIND_WINDOW && h->targetId != ((const WindowT *)target)->id) { + continue; + } + + return h; + } + } + + return NULL; +} + + +static void spyForgetTarget(const void *target) { + for (int32_t i = (int32_t)arrlen(sHooks) - 1; i >= 0; i--) { + if (sHooks[i].target == target) { + arrdel(sHooks, i); + } + } +} + + +static void spyOnWidgetDestroy(WidgetT *w) { + spyForgetTarget(w); +} + + +static void spyReleaseAll(void) { + arrfree(sHooks); + sHooks = NULL; +} + + +static int32_t spySlotIndex(const char *const *names, int32_t count, const char *slot) { + for (int32_t i = 0; i < count; i++) { + if (strcmp(names[i], slot) == 0) { + return i; + } + } + + return -1; +} + + +void tAdvanceClock(int32_t ms) { + hostClockAdvance(ms); + tPump(1); +} + + +void tAltF4(void) { + keyPress(KEY_SCAN_ALT_F4, 0, KEY_MOD_ALT); +} + + +void tAltTab(void) { + keyPress(KEY_SCAN_ALT_TAB, 0, KEY_MOD_ALT); +} + + +void tCase(const char *name) { + sCaseName = name ? name : ""; +} + + +bool tCheckEq(const char *file, int32_t line, const char *expr, int64_t actual, int64_t expected) { + if (actual == expected) { + sPassCount++; + return true; + } + + caseFail(file, line, expr); + fprintf(stderr, " - expected: %lld\n + actual: %lld\n", (long long)expected, (long long)actual); + return false; +} + + +bool tCheckPtrEq(const char *file, int32_t line, const char *expr, const void *actual, const void *expected) { + if (actual == expected) { + sPassCount++; + return true; + } + + caseFail(file, line, expr); + fprintf(stderr, " - expected: %p\n + actual: %p\n", expected, actual); + return false; +} + + +bool tCheckStrEq(const char *file, int32_t line, const char *expr, const char *actual, const char *expected) { + if (actual && expected && strcmp(actual, expected) == 0) { + sPassCount++; + return true; + } + + caseFail(file, line, expr); + fprintf(stderr, " - expected: \"%s\"\n + actual: \"%s\"\n", expected ? expected : "(null)", actual ? actual : "(null)"); + return false; +} + + +bool tCheckTrue(const char *file, int32_t line, const char *expr, bool cond) { + if (cond) { + sPassCount++; + return true; + } + + caseFail(file, line, expr); + return false; +} + + +void tClick(int32_t x, int32_t y) { + mousePush(x, y, 0); + mousePush(x, y, MOUSE_LEFT); + mousePush(x, y, 0); +} + + +AppContextT *tCtx(void) { + return sCtx; +} + + +void tDblClick(int32_t x, int32_t y) { + tClick(x, y); + tClick(x, y); +} + + +bool tDialogClickButton(WindowT *modal, const char *caption) { + WidgetT *btn = modal ? tFindByText(modal->widgetRoot, caption) : NULL; + + if (!btn) { + return false; + } + + tWidgetClick(btn); + return true; +} + + +void tDialogScript(TDialogScriptFnT fn, void *userCtx) { + sDialogFn = fn; + sDialogCtx = userCtx; + + if (fn) { + sCtx->idleCallback = dialogIdle; + sCtx->idleCtx = sCtx; + } else if (sCtx->idleCallback == dialogIdle) { + sCtx->idleCallback = NULL; + sCtx->idleCtx = NULL; + } +} + + +void tDrag(int32_t x0, int32_t y0, int32_t x1, int32_t y1) { + mousePush(x0, y0, 0); + mousePush(x0, y0, MOUSE_LEFT); + mousePush((x0 + x1) / 2, (y0 + y1) / 2, MOUSE_LEFT); + mousePush(x1, y1, MOUSE_LEFT); + mousePush(x1, y1, 0); +} + + +WidgetT *tFind(WindowT *win, const char *name) { + if (!win || !win->widgetRoot) { + return NULL; + } + + return wgtFind(win->widgetRoot, name); +} + + +WidgetT *tFindByText(WidgetT *root, const char *text) { + if (!root) { + return NULL; + } + + if (matchCaption(wgtGetText(root), text)) { + return root; + } + + for (WidgetT *c = root->firstChild; c; c = c->nextSibling) { + WidgetT *hit = tFindByText(c, text); + + if (hit) { + return hit; + } + } + + return NULL; +} + + +WindowT *tFindWindow(const char *title) { + for (int32_t i = 0; i < sCtx->stack.count; i++) { + WindowT *win = sCtx->stack.windows[i]; + + if (strcmp(win->title, title) == 0) { + return win; + } + } + + return NULL; +} + + +void tFKey(int32_t n) { + int32_t scan; + + if (n == FKEY_F11) { + scan = SCAN_F11; + } else if (n == FKEY_F12) { + scan = SCAN_F12; + } else { + scan = KEY_SCAN_F1 + (n - 1); + } + + keyPress(scan, 0, 0); +} + + +bool tGoldenRect(const char *name, int32_t x, int32_t y, int32_t w, int32_t h) { + const DisplayT *d = &sCtx->display; + const uint8_t *fb = hostFramebuffer(d); + + if (!fb || x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > d->width || y + h > d->height) { + return tCheckTrue(__FILE__, __LINE__, "tGoldenRect region inside display", false); + } + + const uint8_t *origin = fb + (size_t)y * (size_t)d->pitch + (size_t)x * (size_t)d->format.bytesPerPixel; + + return goldenCompare(name, origin, d->pitch, w, h); +} + + +void tInit(int32_t w, int32_t h, int32_t bpp) { + if (!sProcessReady) { + fprintf(stderr, "dvxtest: tInit before tProcessInit\n"); + exit(1); + } + + memset(sCtx, 0, sizeof(*sCtx)); + hostClockSet(0); + + if (dvxInit(sCtx, w, h, bpp) != 0) { + fprintf(stderr, "dvxtest: dvxInit(%ld, %ld, %ld) failed\n", (long)w, (long)h, (long)bpp); + exit(1); + } + + // Modules that hook the widget-destroy list from a constructor lost + // their registration in the last wgtTestReset; give it back. + if (textHelpTestReset) { + textHelpTestReset(); + } + + widgetRegisterDestroyFn(spyOnWidgetDestroy); +} + + +void tKey(int32_t scancode, int32_t ascii, int32_t mods) { + keyPress(scancode, ascii, mods); +} + + +void tKeyBackspace(void) { + keyPress(SCAN_BACKSPACE, KEY_BACKSPACE, 0); +} + + +void tKeyEnter(void) { + keyPress(SCAN_ENTER, KEY_ENTER, 0); +} + + +void tKeyEsc(void) { + keyPress(KEY_SCAN_ESC, KEY_ESCAPE, 0); +} + + +// Shift+Tab reaches the event loop with no ASCII code, matching the +// BIOS-style extended scancode the DOS backend delivers. +void tKeyShiftTab(void) { + keyPress(KEY_SCAN_TAB, 0, KEY_MOD_SHIFT); +} + + +void tKeySpace(void) { + keyPress(KEY_SCAN_SPACE, KEY_SPACE, 0); +} + + +void tKeyTab(void) { + keyPress(KEY_SCAN_TAB, KEY_TAB, 0); +} + + +void tMouseDown(int32_t x, int32_t y, int32_t button) { + mousePush(x, y, button); +} + + +void tMouseMove(int32_t x, int32_t y) { + mousePush(x, y, sCtx->mouseButtons); +} + + +void tMouseUp(int32_t x, int32_t y, int32_t button) { + mousePush(x, y, sCtx->mouseButtons & ~button); +} + + +int32_t tProcessInit(const char *widgetDir) { + if (sProcessReady) { + return 0; + } + + sCtx = (AppContextT *)calloc(1, sizeof(AppContextT)); + + if (!sCtx) { + fprintf(stderr, "dvxtest: out of memory\n"); + exit(1); + } + + char **names = dvxReadDir(widgetDir); + int32_t count = 0; + + if (!names) { + fprintf(stderr, "dvxtest: cannot read widget dir %s\n", widgetDir); + exit(1); + } + + for (int32_t i = 0; i < (int32_t)arrlen(names); i++) { + if (!dvxHasExt(names[i], WIDGET_SO_EXT)) { + continue; + } + + char path[DVX_MAX_PATH]; + + snprintf(path, sizeof(path), "%s" DVX_PATH_SEP "%s", widgetDir, names[i]); + + void *handle = dlopen(path, RTLD_LAZY | RTLD_GLOBAL); + + if (!handle) { + fprintf(stderr, "dvxtest: dlopen %s: %s\n", path, dlerror()); + exit(1); + } + + WgtRegisterFnT reg = (WgtRegisterFnT)dlsym(handle, DVX_SYM("wgtRegister")); + + if (!reg) { + fprintf(stderr, "dvxtest: %s has no wgtRegister\n", path); + exit(1); + } + + reg(); + count++; + } + + dvxReadDirFree(names); + sProcessReady = true; + return count; +} + + +void tPump(int32_t n) { + for (int32_t i = 0; i < n; i++) { + dvxUpdate(sCtx); + } +} + + +bool tPumpUntil(TPredFnT pred, void *userCtx, int32_t maxFrames) { + for (int32_t i = 0; i < maxFrames; i++) { + if (pred(userCtx)) { + return true; + } + + dvxUpdate(sCtx); + } + + return pred(userCtx); +} + + +static void trNOnBlur(WindowT *win) { + SPY_WINDOW_PROLOGUE(NSlotOnBlurE, win, 0, 0, 0, 0) + + if (prev) { + ((void (*)(WindowT *))prev)(win); + } +} + + +static void trNOnClose(WindowT *win) { + SPY_WINDOW_PROLOGUE(NSlotOnCloseE, win, 0, 0, 0, 0) + + if (prev) { + ((void (*)(WindowT *))prev)(win); + } +} + + +static void trNOnFocus(WindowT *win) { + SPY_WINDOW_PROLOGUE(NSlotOnFocusE, win, 0, 0, 0, 0) + + if (prev) { + ((void (*)(WindowT *))prev)(win); + } +} + + +static void trNOnKey(WindowT *win, int32_t key, int32_t mod) { + SPY_WINDOW_PROLOGUE(NSlotOnKeyE, win, 2, key, mod, 0) + + if (prev) { + ((void (*)(WindowT *, int32_t, int32_t))prev)(win, key, mod); + } +} + + +static void trNOnKeyUp(WindowT *win, int32_t scancode, int32_t mod) { + SPY_WINDOW_PROLOGUE(NSlotOnKeyUpE, win, 2, scancode, mod, 0) + + if (prev) { + ((void (*)(WindowT *, int32_t, int32_t))prev)(win, scancode, mod); + } +} + + +static void trNOnMenu(WindowT *win, int32_t menuId) { + SPY_WINDOW_PROLOGUE(NSlotOnMenuE, win, 1, menuId, 0, 0) + + if (prev) { + ((void (*)(WindowT *, int32_t))prev)(win, menuId); + } +} + + +static void trNOnMouse(WindowT *win, int32_t x, int32_t y, int32_t buttons) { + SPY_WINDOW_PROLOGUE(NSlotOnMouseE, win, 3, x, y, buttons) + + if (prev) { + ((void (*)(WindowT *, int32_t, int32_t, int32_t))prev)(win, x, y, buttons); + } +} + + +static void trNOnPaint(WindowT *win, RectT *dirtyArea) { + SPY_WINDOW_PROLOGUE(NSlotOnPaintE, win, 2, dirtyArea->w, dirtyArea->h, 0) + + if (prev) { + ((void (*)(WindowT *, RectT *))prev)(win, dirtyArea); + } +} + + +static void trNOnResize(WindowT *win, int32_t newW, int32_t newH) { + SPY_WINDOW_PROLOGUE(NSlotOnResizeE, win, 2, newW, newH, 0) + + if (prev) { + ((void (*)(WindowT *, int32_t, int32_t))prev)(win, newW, newH); + } +} + + +static void trNOnScroll(WindowT *win, ScrollbarOrientE orient, int32_t value) { + SPY_WINDOW_PROLOGUE(NSlotOnScrollE, win, 2, (int32_t)orient, value, 0) + + if (prev) { + ((void (*)(WindowT *, ScrollbarOrientE, int32_t))prev)(win, orient, value); + } +} + + +static void trWOnBlur(WidgetT *w) { + SPY_WIDGET_PROLOGUE(WSlotOnBlurE, w, 0, 0, 0, 0) + + if (prev) { + ((void (*)(WidgetT *))prev)(w); + } +} + + +static void trWOnChange(WidgetT *w) { + SPY_WIDGET_PROLOGUE(WSlotOnChangeE, w, 0, 0, 0, 0) + + if (prev) { + ((void (*)(WidgetT *))prev)(w); + } +} + + +static void trWOnClick(WidgetT *w) { + SPY_WIDGET_PROLOGUE(WSlotOnClickE, w, 0, 0, 0, 0) + + if (prev) { + ((void (*)(WidgetT *))prev)(w); + } +} + + +static void trWOnDblClick(WidgetT *w) { + SPY_WIDGET_PROLOGUE(WSlotOnDblClickE, w, 0, 0, 0, 0) + + if (prev) { + ((void (*)(WidgetT *))prev)(w); + } +} + + +static void trWOnFocus(WidgetT *w) { + SPY_WIDGET_PROLOGUE(WSlotOnFocusE, w, 0, 0, 0, 0) + + if (prev) { + ((void (*)(WidgetT *))prev)(w); + } +} + + +static void trWOnKeyDown(WidgetT *w, int32_t keyCode, int32_t shift) { + SPY_WIDGET_PROLOGUE(WSlotOnKeyDownE, w, 2, keyCode, shift, 0) + + if (prev) { + ((void (*)(WidgetT *, int32_t, int32_t))prev)(w, keyCode, shift); + } +} + + +static void trWOnKeyPress(WidgetT *w, int32_t keyAscii) { + SPY_WIDGET_PROLOGUE(WSlotOnKeyPressE, w, 1, keyAscii, 0, 0) + + if (prev) { + ((void (*)(WidgetT *, int32_t))prev)(w, keyAscii); + } +} + + +static void trWOnKeyUp(WidgetT *w, int32_t keyCode, int32_t shift) { + SPY_WIDGET_PROLOGUE(WSlotOnKeyUpE, w, 2, keyCode, shift, 0) + + if (prev) { + ((void (*)(WidgetT *, int32_t, int32_t))prev)(w, keyCode, shift); + } +} + + +static void trWOnMouseDown(WidgetT *w, int32_t button, int32_t x, int32_t y) { + SPY_WIDGET_PROLOGUE(WSlotOnMouseDownE, w, 3, button, x, y) + + if (prev) { + ((void (*)(WidgetT *, int32_t, int32_t, int32_t))prev)(w, button, x, y); + } +} + + +static void trWOnMouseMove(WidgetT *w, int32_t button, int32_t x, int32_t y) { + SPY_WIDGET_PROLOGUE(WSlotOnMouseMoveE, w, 3, button, x, y) + + if (prev) { + ((void (*)(WidgetT *, int32_t, int32_t, int32_t))prev)(w, button, x, y); + } +} + + +static void trWOnMouseUp(WidgetT *w, int32_t button, int32_t x, int32_t y) { + SPY_WIDGET_PROLOGUE(WSlotOnMouseUpE, w, 3, button, x, y) + + if (prev) { + ((void (*)(WidgetT *, int32_t, int32_t, int32_t))prev)(w, button, x, y); + } +} + + +static void trWOnScroll(WidgetT *w, int32_t delta) { + SPY_WIDGET_PROLOGUE(WSlotOnScrollE, w, 1, delta, 0, 0) + + if (prev) { + ((void (*)(WidgetT *, int32_t))prev)(w, delta); + } +} + + +static bool trWOnValidate(WidgetT *w) { + SPY_WIDGET_PROLOGUE(WSlotOnValidateE, w, 0, 0, 0, 0) + + if (prev) { + return ((bool (*)(WidgetT *))prev)(w); + } + + return true; +} + + +void tSetGoldenDir(const char *dir) { + sGoldenDir = dir; +} + + +void tShutdown(void) { + tDialogScript(NULL, NULL); + dvxShutdown(sCtx); + spyReleaseAll(); + wgtTestReset(); + dvxDialogTestReset(); + + if (basFormRtTestReset) { + basFormRtTestReset(); + } + + removeTempDir(); +} + + +void tSpyClear(TSpyT *spy) { + arrsetlen(spy->entries, 0); +} + + +int32_t tSpyCount(const TSpyT *spy, const char *name) { + int32_t n = 0; + + for (int32_t i = 0; i < (int32_t)arrlen(spy->entries); i++) { + if (strcmp(spy->entries[i].text, name) == 0 || strcmp(spy->entries[i].slot, name) == 0) { + n++; + } + } + + return n; +} + + +void tSpyFree(TSpyT *spy) { + for (int32_t i = (int32_t)arrlen(sHooks) - 1; i >= 0; i--) { + if (sHooks[i].spy == spy) { + arrdel(sHooks, i); + } + } + + arrfree(spy->entries); + spy->entries = NULL; +} + + +void tSpyInit(TSpyT *spy) { + spy->entries = NULL; +} + + +const char *tSpyLog(const TSpyT *spy) { + int32_t used = 0; + + sSpyLogBuf[0] = '\0'; + + for (int32_t i = 0; i < (int32_t)arrlen(spy->entries); i++) { + int32_t n = snprintf(sSpyLogBuf + used, sizeof(sSpyLogBuf) - (size_t)used, "%s%s", i ? "," : "", spy->entries[i].text); + + if (n < 0 || used + n >= (int32_t)sizeof(sSpyLogBuf)) { + break; + } + + used += n; + } + + return sSpyLogBuf; +} + + +bool tSpyOrder(const TSpyT *spy, const char *csv) { + return strcmp(tSpyLog(spy), csv) == 0; +} + + +bool tSpyWidget(TSpyT *spy, WidgetT *w, const char *slot) { + int32_t idx = spySlotIndex(sWidgetSlotNames, WSlotCountE, slot); + + if (!w || idx < 0 || spyFindHook(SPY_KIND_WIDGET, idx, w)) { + return false; + } + + // The callback pointers are laid out in slot order, so index the + // first one as an array. + void **slots = (void **)&w->onClick; + SpyHookT h; + + h.kind = SPY_KIND_WIDGET; + h.slot = idx; + h.target = w; + h.targetId = -1; + h.spy = spy; + h.prevFn = slots[idx]; + slots[idx] = sWidgetTrampolines[idx]; + arrput(sHooks, h); + return true; +} + + +bool tSpyWindow(TSpyT *spy, WindowT *win, const char *slot) { + int32_t idx = spySlotIndex(sWindowSlotNames, NSlotCountE, slot); + + if (!win || idx < 0 || spyFindHook(SPY_KIND_WINDOW, idx, win)) { + return false; + } + + void **base = (void **)&win->onPaint; + void **cell; + + // WindowT slots are not contiguous (onCursorQuery sits between + // onScroll and onFocus), so resolve each explicitly. + switch ((WindowSlotE)idx) { + case NSlotOnPaintE: + cell = (void **)&win->onPaint; + break; + case NSlotOnKeyE: + cell = (void **)&win->onKey; + break; + case NSlotOnKeyUpE: + cell = (void **)&win->onKeyUp; + break; + case NSlotOnMouseE: + cell = (void **)&win->onMouse; + break; + case NSlotOnResizeE: + cell = (void **)&win->onResize; + break; + case NSlotOnCloseE: + cell = (void **)&win->onClose; + break; + case NSlotOnMenuE: + cell = (void **)&win->onMenu; + break; + case NSlotOnScrollE: + cell = (void **)&win->onScroll; + break; + case NSlotOnFocusE: + cell = (void **)&win->onFocus; + break; + case NSlotOnBlurE: + cell = (void **)&win->onBlur; + break; + default: + cell = base; + break; + } + + SpyHookT h; + + h.kind = SPY_KIND_WINDOW; + h.slot = idx; + h.target = win; + h.targetId = win->id; + h.spy = spy; + h.prevFn = *cell; + *cell = sWindowTrampolines[idx]; + arrput(sHooks, h); + return true; +} + + +void tSuiteBegin(const char *name) { + sSuiteName = name ? name : ""; + sCaseName = ""; + sPassCount = 0; + sFailCount = 0; + printf("=== %s ===\n", sSuiteName); +} + + +int32_t tSuiteEnd(void) { + removeTempDir(); + printf("%s: %ld passed, %ld failed\n", sSuiteName, (long)sPassCount, (long)sFailCount); + fflush(stdout); + return sFailCount != 0 ? 1 : 0; +} + + +const char *tTempDir(void) { + if (sTempDir[0]) { + return sTempDir; + } + + const char *base = getenv(TEMP_DIR_ENV); + + if (!base || !base[0]) { + base = TEMP_DIR_DEFAULT; + } + + snprintf(sTempDir, sizeof(sTempDir), "%s" DVX_PATH_SEP TEMP_DIR_TEMPLATE, base); + + if (!mkdtemp(sTempDir)) { + fprintf(stderr, "dvxtest: mkdtemp %s failed\n", sTempDir); + sTempDir[0] = '\0'; + exit(1); + } + + return sTempDir; +} + + +void tType(const char *text) { + for (const char *p = text; *p; p++) { + bool shift = false; + int32_t scan = scanForChar(*p, &shift); + + keyPress(scan, (int32_t)(uint8_t)*p, shift ? KEY_MOD_SHIFT : 0); + } +} + + +void tWheel(int32_t delta) { + hostWheelPush(delta); + tPump(1); +} + + +void tWidgetCenter(const WidgetT *w, int32_t *sx, int32_t *sy) { + const WindowT *win = w->window; + + *sx = win->x + win->contentX + w->x + w->w / 2; + *sy = win->y + win->contentY + w->y + w->h / 2; +} + + +void tWidgetClick(WidgetT *w) { + if (!w || !w->window) { + return; + } + + WindowT *win = w->window; + int32_t cx = w->x + w->w / 2; + int32_t cy = w->y + w->h / 2; + + widgetOnMouse(win, cx, cy, 0); + widgetOnMouse(win, cx, cy, MOUSE_LEFT); + widgetOnMouse(win, cx, cy, 0); +} + + +void tWidgetKey(WindowT *win, int32_t key, int32_t mods) { + if (!win) { + return; + } + + widgetOnKey(win, key, mods); +} + + +bool tWindowGolden(const char *name, WindowT *win) { + if (!win || !win->contentBuf) { + return tCheckTrue(__FILE__, __LINE__, "tWindowGolden window has content", false); + } + + return goldenCompare(name, win->contentBuf, win->contentPitch, win->contentW, win->contentH); +} + + +bool tWriteFile(const char *path, const void *data, int32_t len) { + FILE *f = fopen(path, "wb"); + + if (!f) { + return false; + } + + bool ok = len <= 0 || fwrite(data, 1, (size_t)len, f) == (size_t)len; + + fclose(f); + return ok; +} + + +static void widgetLabel(const WidgetT *w, char *out, int32_t outSize) { + snprintf(out, outSize, "%s", w->name); +} + + +static void windowLabel(const WindowT *win, char *out, int32_t outSize) { + snprintf(out, outSize, "%s", win->title); +} diff --git a/src/test/dvxtest/dvxtest.h b/src/test/dvxtest/dvxtest.h new file mode 100644 index 0000000..9a9f0e9 --- /dev/null +++ b/src/test/dvxtest/dvxtest.h @@ -0,0 +1,241 @@ +// 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. + +// dvxtest.h -- host-side test runner library for the DVX GUI stack +// +// Links against the native host build of libdvx (obj/host/libdvx.a) and +// the widget shared objects. A suite calls tProcessInit once, then for +// every case tInit / ... / tShutdown. Input helpers push samples into +// the host platform queues and pump exactly the frames needed so a test +// can assert immediately afterwards. See README.md for the full tour. + +#ifndef DVXTEST_H +#define DVXTEST_H + +#include "dvxApp.h" +#include "dvxWgt.h" +#include "dvxDlg.h" +#include "dvxPlatHost.h" + +#include +#include + +// ============================================================ +// Assertions +// ============================================================ + +// Every macro records one pass or fail and, on failure, prints +// file:line with expected/actual in a diff-style layout. They never +// abort the case; check tSuiteEnd's return value for the verdict. +#define TEST_TRUE(cond) tCheckTrue(__FILE__, __LINE__, #cond, (cond)) +#define TEST_EQ(a, b) tCheckEq(__FILE__, __LINE__, #a " == " #b, (int64_t)(a), (int64_t)(b)) +#define TEST_STR_EQ(a, b) tCheckStrEq(__FILE__, __LINE__, #a " == " #b, (a), (b)) +#define TEST_PTR_EQ(a, b) tCheckPtrEq(__FILE__, __LINE__, #a " == " #b, (const void *)(a), (const void *)(b)) + +bool tCheckEq(const char *file, int32_t line, const char *expr, int64_t actual, int64_t expected); +bool tCheckPtrEq(const char *file, int32_t line, const char *expr, const void *actual, const void *expected); +bool tCheckStrEq(const char *file, int32_t line, const char *expr, const char *actual, const char *expected); +bool tCheckTrue(const char *file, int32_t line, const char *expr, bool cond); + +// ============================================================ +// Suite lifecycle +// ============================================================ + +// tSuiteBegin names the suite; tCase names the case that follows (used +// in failure output); tSuiteEnd prints the pass/fail totals and returns +// the process exit code (non-zero when anything failed). +void tCase(const char *name); +void tSuiteBegin(const char *name); +int32_t tSuiteEnd(void); + +// ============================================================ +// Process and context +// ============================================================ + +// Load every widget .so in widgetDir ONCE per process (class ids are +// index-based and must never be re-registered). Returns the count. +int32_t tProcessInit(const char *widgetDir); + +// Bring the GUI stack up on an offscreen display / tear it down. tInit +// resets the fake clock to zero; tShutdown runs every static-state +// reset hook so the next tInit starts from load-time state. +void tInit(int32_t w, int32_t h, int32_t bpp); +// Override the directory golden PNGs are read from and written to. +void tSetGoldenDir(const char *dir); + +void tShutdown(void); + +// The context every helper drives. +AppContextT *tCtx(void); + +// ============================================================ +// Frame pumping and clock +// ============================================================ + +typedef bool (*TPredFnT)(void *userCtx); + +// Run n event-loop frames. +void tPump(int32_t n); + +// Pump until pred returns true or maxFrames elapse; returns pred's +// final value. +bool tPumpUntil(TPredFnT pred, void *userCtx, int32_t maxFrames); + +// Advance the fake clock by ms and pump one frame so timers, blink and +// double-click logic observe the new time. +void tAdvanceClock(int32_t ms); + +// ============================================================ +// Input through the platform queues (full chrome / hit-test path) +// ============================================================ + +// Each helper queues its samples and pumps one frame per sample, so the +// effect is complete on return. Coordinates are screen pixels. +void tClick(int32_t x, int32_t y); +void tDblClick(int32_t x, int32_t y); +void tDrag(int32_t x0, int32_t y0, int32_t x1, int32_t y1); +void tMouseDown(int32_t x, int32_t y, int32_t button); +void tMouseMove(int32_t x, int32_t y); +void tMouseUp(int32_t x, int32_t y, int32_t button); +void tWheel(int32_t delta); + +// Queue one key (PC set-1 scancode, ascii or 0 for extended keys, BIOS +// modifier byte) and pump one frame. +void tAltF4(void); +void tAltTab(void); +void tFKey(int32_t n); +void tKey(int32_t scancode, int32_t ascii, int32_t mods); +void tKeyBackspace(void); +void tKeyEnter(void); +void tKeyEsc(void); +void tKeyShiftTab(void); +void tKeySpace(void); +void tKeyTab(void); + +// Type printable ASCII text one key per frame (shift is applied for +// upper case and shifted symbols). +void tType(const char *text); + +// ============================================================ +// Direct dispatch (content-relative, no chrome, deterministic) +// ============================================================ + +// Move/press/release on the widget's centre through widgetOnMouse. +void tWidgetClick(WidgetT *w); + +// Deliver one key to the window's focused widget through widgetOnKey. +void tWidgetKey(WindowT *win, int32_t key, int32_t mods); + +// ============================================================ +// Locators +// ============================================================ + +WidgetT *tFind(WindowT *win, const char *name); +WindowT *tFindWindow(const char *title); + +// First widget under root whose text matches (accelerator '&' ignored). +WidgetT *tFindByText(WidgetT *root, const char *text); + +// Widget centre in screen coordinates (content -> frame -> screen). +void tWidgetCenter(const WidgetT *w, int32_t *sx, int32_t *sy); + +// ============================================================ +// Callback spies +// ============================================================ + +#define T_SPY_MAX_ARGS 3 + +typedef struct { + char text[80]; // "label.slot" or "slot" + char slot[24]; // callback slot name + void *target; // WidgetT or WindowT at the time + int32_t targetId; // window id, or -1 for widgets + int32_t args[T_SPY_MAX_ARGS]; // callback arguments (0 when unused) + int32_t argCount; + uint32_t widgetGen; // sWidgetGen when the entry was logged +} TSpyEntryT; + +typedef struct { + TSpyEntryT *entries; // stb_ds dynamic array +} TSpyT; + +// Attach to a WidgetT slot (onClick, onDblClick, onChange, onFocus, +// onBlur, onKeyPress, onKeyDown, onKeyUp, onMouseDown, onMouseUp, +// onMouseMove, onScroll, onValidate) or a WindowT slot (onPaint, onKey, +// onKeyUp, onMouse, onResize, onClose, onMenu, onScroll, onFocus, +// onBlur). The previous callback keeps running after the log entry is +// recorded. Entries are labelled with the widget name or window title. +// Attachments die with their widget (destroy subscriber) and with the +// context (tShutdown). +void tSpyClear(TSpyT *spy); +void tSpyFree(TSpyT *spy); +void tSpyInit(TSpyT *spy); +bool tSpyWidget(TSpyT *spy, WidgetT *w, const char *slot); +bool tSpyWindow(TSpyT *spy, WindowT *win, const char *slot); + +// Entries whose full text or slot name equals name. +int32_t tSpyCount(const TSpyT *spy, const char *name); + +// The log as "a,b,c" (static buffer, valid until the next call). +const char *tSpyLog(const TSpyT *spy); + +// True when the log's full texts are exactly the comma-separated list. +bool tSpyOrder(const TSpyT *spy, const char *csv); + +// ============================================================ +// Modal dialog driver +// ============================================================ + +typedef void (*TDialogScriptFnT)(AppContextT *ctx, WindowT *modal, void *userCtx); + +// Install fn as the context's idle callback. It runs on every idle +// frame while a modal window is up, so it can be installed BEFORE +// calling a blocking dialog and drive it from inside the nested +// dvxUpdate loop. Pass NULL to remove. +void tDialogScript(TDialogScriptFnT fn, void *userCtx); + +// Click the button with the given caption inside modal (direct +// dispatch). Returns false when no such button exists. +bool tDialogClickButton(WindowT *modal, const char *caption); + +// ============================================================ +// Golden images +// ============================================================ + +// Compare a framebuffer region / a window's content buffer with +// golden/.png. A mismatch writes golden/.actual.png; +// UPDATE_GOLDENS=1 in the environment rewrites the golden instead. +// Both record a pass/fail like the TEST_ macros. +bool tGoldenRect(const char *name, int32_t x, int32_t y, int32_t w, int32_t h); +bool tWindowGolden(const char *name, WindowT *win); + +// ============================================================ +// Fixtures +// ============================================================ + +// A fresh temporary directory (removed by tShutdown/tSuiteEnd). +const char *tTempDir(void); + +// Write bytes to path; returns false on error. +bool tWriteFile(const char *path, const void *data, int32_t len); + +#endif // DVXTEST_H diff --git a/src/test/dvxtest/dvxtest.mk b/src/test/dvxtest/dvxtest.mk new file mode 100644 index 0000000..098758d --- /dev/null +++ b/src/test/dvxtest/dvxtest.mk @@ -0,0 +1,108 @@ +# 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. + +# dvxtest.mk -- shared fragment for host-side GUI test suites. +# +# Include from a suite Makefile: +# +# include ../dvxtest/dvxtest.mk +# mySuite: mySuite.c $(DVXTEST_LIB) +# $(HOSTCC) $(DVXTEST_CFLAGS) -o $@ mySuite.c $(DVXTEST_LINK) +# +# Provides: DVXTEST_CFLAGS (includes + sanitizer + golden/widget dir +# defines), DVXTEST_LINK (whole-archive libdvx + libdvxtest + system +# libs), DVXTEST_LIB (the archive, with a rule that also builds +# obj/host/libdvx.a and every widget .so), DVXTEST_WIDGET_DIR. +# SAN=1 (default) enables ASan/UBSan; the flags must match the host +# libraries, so pass the same SAN to every make in one build. COV=1 +# switches to a gcov-instrumented build under obj/hostcov (see +# src/test/coverage). + +SAN ?= 1 +HOSTCC ?= gcc + +DVXTEST_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +DVXTEST_ROOT := $(abspath $(DVXTEST_DIR)/../../..) +# COV=1 (make coverage): gcov instrumentation, everything under +# obj/hostcov so it never mixes with the sanitized build in obj/host. +ifeq ($(COV),1) +DVXTEST_HOSTSUB := hostcov +DVXTEST_COVFLAGS := --coverage +DVXTEST_COVLDFLAGS:= --coverage +else +DVXTEST_HOSTSUB := host +DVXTEST_COVFLAGS := +DVXTEST_COVLDFLAGS:= +endif +DVXTEST_OBJDIR := $(DVXTEST_ROOT)/obj/$(DVXTEST_HOSTSUB) +DVXTEST_BINDIR := $(DVXTEST_OBJDIR)/test +DVXTEST_LIBDVX := $(DVXTEST_ROOT)/src/libs/kpunch/libdvx +DVXTEST_LIBTASKS := $(DVXTEST_ROOT)/src/libs/kpunch/libtasks +DVXTEST_TEXTHELP := $(DVXTEST_ROOT)/src/libs/kpunch/texthelp +DVXTEST_LISTHELP := $(DVXTEST_ROOT)/src/libs/kpunch/listhelp +DVXTEST_WIDGETS := $(DVXTEST_ROOT)/src/widgets/kpunch +DVXTEST_WIDGET_DIR:= $(DVXTEST_OBJDIR)/widgets +DVXTEST_GOLDEN_DIR?= $(DVXTEST_DIR)/golden +DVXTEST_LIB := $(DVXTEST_BINDIR)/libdvxtest.a + +DVXTEST_INCLUDES := -I$(DVXTEST_DIR) -I$(DVXTEST_LIBDVX) -I$(DVXTEST_LIBDVX)/platform -I$(DVXTEST_LIBDVX)/thirdparty -I$(DVXTEST_LIBTASKS) -I$(DVXTEST_WIDGETS) -I$(DVXTEST_TEXTHELP) -I$(DVXTEST_LISTHELP) +DVXTEST_WARN := -Wall -Wextra -Werror -Wno-type-limits -Wno-sign-compare -Wno-format-truncation +DVXTEST_CFLAGS := -O1 -g $(DVXTEST_WARN) -D_GNU_SOURCE $(DVXTEST_INCLUDES) -DDVXTEST_WIDGET_DIR='"$(DVXTEST_WIDGET_DIR)"' -DDVXTEST_GOLDEN_DIR='"$(DVXTEST_GOLDEN_DIR)"' $(DVXTEST_COVFLAGS) +DVXTEST_LDFLAGS := -rdynamic $(DVXTEST_COVLDFLAGS) +ifeq ($(SAN),1) +DVXTEST_CFLAGS += -fsanitize=address,undefined -fno-omit-frame-pointer +DVXTEST_LDFLAGS += -fsanitize=address,undefined +endif +DVXTEST_LINK := $(DVXTEST_LDFLAGS) -Wl,--whole-archive $(DVXTEST_OBJDIR)/libdvx.a $(DVXTEST_LIB) -Wl,--no-whole-archive -ldl -lm + +# Sources folded into libdvxtest.a: the runner plus the helper libraries +# widget .so files resolve from the executable at load time. +DVXTEST_LIB_SRCS := $(DVXTEST_DIR)/dvxtest.c $(DVXTEST_TEXTHELP)/textHelp.c $(DVXTEST_LISTHELP)/listHelp.c +DVXTEST_LIB_OBJS := $(patsubst %.c,$(DVXTEST_BINDIR)/lib/%.o,$(notdir $(DVXTEST_LIB_SRCS))) +DVXTEST_HDRS := $(wildcard $(DVXTEST_DIR)/*.h $(DVXTEST_LIBDVX)/*.h $(DVXTEST_LIBDVX)/platform/*.h $(DVXTEST_TEXTHELP)/*.h $(DVXTEST_LISTHELP)/*.h) + +.PHONY: dvxtest-libs dvxtest-clean + +# Host core, widget shared objects and the runner archive. +dvxtest-libs: + $(MAKE) -C $(DVXTEST_LIBDVX) host SAN=$(SAN) + $(MAKE) -C $(DVXTEST_WIDGETS) host SAN=$(SAN) + $(MAKE) -f $(DVXTEST_DIR)/dvxtest.mk $(DVXTEST_LIB) SAN=$(SAN) + +$(DVXTEST_LIB): $(DVXTEST_LIB_OBJS) + rm -f $@ + ar rcs $@ $(DVXTEST_LIB_OBJS) + +$(DVXTEST_BINDIR)/lib/%.o: $(DVXTEST_DIR)/%.c $(DVXTEST_HDRS) + @mkdir -p $(dir $@) + $(HOSTCC) $(DVXTEST_CFLAGS) -c -o $@ $< + +$(DVXTEST_BINDIR)/lib/%.o: $(DVXTEST_TEXTHELP)/%.c $(DVXTEST_HDRS) + @mkdir -p $(dir $@) + $(HOSTCC) $(DVXTEST_CFLAGS) -c -o $@ $< + +$(DVXTEST_BINDIR)/lib/%.o: $(DVXTEST_LISTHELP)/%.c $(DVXTEST_HDRS) + @mkdir -p $(dir $@) + $(HOSTCC) $(DVXTEST_CFLAGS) -c -o $@ $< + +dvxtest-clean: + rm -rf $(DVXTEST_BINDIR)/lib $(DVXTEST_LIB) diff --git a/src/test/dvxtest/golden/covTextAreaSel.png b/src/test/dvxtest/golden/covTextAreaSel.png new file mode 100644 index 0000000..131867b --- /dev/null +++ b/src/test/dvxtest/golden/covTextAreaSel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e457c9a2b09bba9ab94f29f98a039ee515ba208ba9a01e5da2e1f1dce93c864d +size 2770 diff --git a/src/test/dvxtest/golden/covTextInputSel.png b/src/test/dvxtest/golden/covTextInputSel.png new file mode 100644 index 0000000..648d1db --- /dev/null +++ b/src/test/dvxtest/golden/covTextInputSel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7eb0d3808f6a1c3b51154215ffa0bb66aa60f0122edf4a4875d829558296b865 +size 1969 diff --git a/src/test/dvxtest/golden/selfButton.png b/src/test/dvxtest/golden/selfButton.png new file mode 100644 index 0000000..52534e7 --- /dev/null +++ b/src/test/dvxtest/golden/selfButton.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:828b2ce055cace08e8b8374362a51ba1aea418ecad772b3ce80544026fb36f42 +size 521 diff --git a/src/test/dvxtest/golden/selfWindow.png b/src/test/dvxtest/golden/selfWindow.png new file mode 100644 index 0000000..991a9ab --- /dev/null +++ b/src/test/dvxtest/golden/selfWindow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e76b5b442f7f0e2b5c9d3f56d43f7a7345db76efa1f9f31a1e6721447b7af08 +size 1782 diff --git a/src/test/dvxtest/selfTest.c b/src/test/dvxtest/selfTest.c new file mode 100644 index 0000000..095acde --- /dev/null +++ b/src/test/dvxtest/selfTest.c @@ -0,0 +1,453 @@ +// 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. + +// selfTest.c -- exercises every dvxtest helper against the real stack +// +// Also the dangling-static regression test: the last case brings the +// GUI up and down fifty times in one process with input in between, +// which only survives when every reset hook does its job. + +#include "dvxtest.h" +#include "box/box.h" +#include "button/button.h" +#include "label/label.h" +#include "textInput/textInpt.h" +#include "timer/timer.h" + +#include +#include + +#define SCREEN_W 640 +#define SCREEN_H 480 +#define SCREEN_BPP 32 + +#define WIN_X 100 +#define WIN_Y 80 +#define WIN_W 260 +#define WIN_H 180 + +#define SETTLE_FRAMES 3 +#define TEXT_MAX_LEN 64 +#define TIMER_INTERVAL_MS 100 +#define TIMER_FIRES 7 +#define CYCLE_COUNT 50 +#define PUMP_LIMIT 20 +#define FKEY_F5 5 +#define FILE_PAYLOAD "hello fixture" + + +typedef struct { + WidgetT *btn; + WidgetT *input; + WidgetT *label; + WidgetT *timer; + WindowT *win; +} FixtureT; + + +// ============================================================ +// Prototypes +// ============================================================ + +static FixtureT buildFixture(const char *title); +static void caseClickPaths(void); +static void caseCycleInit(void); +static void caseDialog(void); +static void caseFixtureFiles(void); +static void caseGolden(void); +static void caseKeys(void); +static void caseSpies(void); +static void caseTimer(void); +static void caseTyping(void); +static void dialogScript(AppContextT *ctx, WindowT *modal, void *userCtx); +static void onClickCount(WidgetT *w); +static void onTimerTick(WidgetT *w); +static bool predClicked(void *userCtx); + + +// ============================================================ +// Module state +// ============================================================ + +static int32_t sClicks = 0; +static int32_t sTimerTicks = 0; + + +static FixtureT buildFixture(const char *title) { + FixtureT f; + + memset(&f, 0, sizeof(f)); + f.win = dvxCreateWindow(tCtx(), title, WIN_X, WIN_Y, WIN_W, WIN_H, false); + + WidgetT *root = wgtInitWindow(tCtx(), f.win); + WidgetT *box = wgtVBox(root); + + f.btn = wgtButton(box, "Click Me"); + f.input = wgtTextInput(box, TEXT_MAX_LEN); + f.label = wgtLabel(box, "Status"); + f.timer = wgtTimer(box, TIMER_INTERVAL_MS, true); + + wgtSetName(f.btn, "btn"); + wgtSetName(f.input, "input"); + wgtSetName(f.label, "lbl"); + wgtTimerStop(f.timer); + f.btn->onClick = onClickCount; + f.timer->onChange = onTimerTick; + + wgtInvalidate(root); + tPump(SETTLE_FRAMES); + return f; +} + + +static void caseClickPaths(void) { + tCase("click via queue and direct dispatch"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + FixtureT f = buildFixture("Clicks"); + int32_t sx = 0; + int32_t sy = 0; + + sClicks = 0; + TEST_PTR_EQ(tFindWindow("Clicks"), f.win); + TEST_PTR_EQ(tFind(f.win, "btn"), f.btn); + TEST_TRUE(f.btn->w > 0 && f.btn->h > 0); + + tWidgetCenter(f.btn, &sx, &sy); + tClick(sx, sy); + TEST_EQ(sClicks, 1); + + tWidgetClick(f.btn); + TEST_EQ(sClicks, 2); + + tMouseMove(sx, sy); + tMouseDown(sx, sy, MOUSE_LEFT); + tMouseUp(sx, sy, MOUSE_LEFT); + TEST_TRUE(tPumpUntil(predClicked, &sClicks, PUMP_LIMIT)); + TEST_EQ(sClicks, 3); + + tDblClick(sx, sy); + TEST_EQ(sClicks, 5); + + tShutdown(); +} + + +static void caseCycleInit(void) { + tCase("tShutdown/tInit cycles"); + + for (int32_t i = 0; i < CYCLE_COUNT; i++) { + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + FixtureT f = buildFixture("Cycle"); + int32_t sx = 0; + int32_t sy = 0; + + sClicks = 0; + tWidgetCenter(f.btn, &sx, &sy); + tClick(sx, sy); + tWidgetCenter(f.input, &sx, &sy); + tClick(sx, sy); + tType("x"); + wgtTimerStart(f.timer); + tAdvanceClock(TIMER_INTERVAL_MS); + tKeyTab(); + tAltTab(); + tWheel(1); + tShutdown(); + + if (sClicks != 1) { + TEST_EQ(sClicks, 1); + return; + } + } + + TEST_EQ(sClicks, 1); +} + + +static void caseDialog(void) { + tCase("message box driven by tDialogScript"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + int32_t seen = 0; + + tDialogScript(dialogScript, &seen); + + int32_t result = dvxMessageBox(tCtx(), "Question", "Continue?", MB_OK | MB_ICONQUESTION); + + TEST_EQ(result, ID_OK); + TEST_TRUE(seen >= 1); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + tPump(1); + TEST_PTR_EQ(tFindWindow("Question"), NULL); + tShutdown(); +} + + +static void caseFixtureFiles(void) { + tCase("temp dir and file fixtures"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + char path[DVX_MAX_PATH]; + + snprintf(path, sizeof(path), "%s/payload.txt", tTempDir()); + TEST_TRUE(tWriteFile(path, FILE_PAYLOAD, (int32_t)strlen(FILE_PAYLOAD))); + + FILE *f = fopen(path, "rb"); + + TEST_TRUE(f != NULL); + + if (f) { + char buf[sizeof(FILE_PAYLOAD)]; + size_t n = fread(buf, 1, sizeof(buf) - 1, f); + + buf[n] = '\0'; + TEST_STR_EQ(buf, FILE_PAYLOAD); + fclose(f); + } + + tShutdown(); +} + + +static void caseGolden(void) { + tCase("golden crops"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + FixtureT f = buildFixture("Golden"); + int32_t sx = 0; + int32_t sy = 0; + + tWidgetCenter(f.btn, &sx, &sy); + tGoldenRect("selfButton", sx - f.btn->w / 2, sy - f.btn->h / 2, f.btn->w, f.btn->h); + tWindowGolden("selfWindow", f.win); + tShutdown(); +} + + +static void caseKeys(void) { + tCase("keyboard chords"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + FixtureT a = buildFixture("KeysA"); + FixtureT b = buildFixture("KeysB"); + TSpyT spy; + + tSpyInit(&spy); + tSpyWindow(&spy, a.win, "onFocus"); + tSpyWindow(&spy, a.win, "onClose"); + + TEST_TRUE(b.win->focused); + tAltTab(); + TEST_TRUE(a.win->focused); + TEST_EQ(tSpyCount(&spy, "KeysA.onFocus"), 1); + + // A spied onClose on a window that had none swallows the default + // destroy, so KeysA survives Alt+F4 while KeysB (unspied) closes. + tAltF4(); + TEST_EQ(tSpyCount(&spy, "onClose"), 1); + TEST_PTR_EQ(tFindWindow("KeysA"), a.win); + + tAltTab(); + TEST_TRUE(b.win->focused); + tAltF4(); + tPump(1); + TEST_PTR_EQ(tFindWindow("KeysB"), NULL); + TEST_TRUE(a.win->focused); + + // Tab moves focus from the button to the text input, Space presses + // the button, Enter and Esc reach the focused widget without harm. + sClicks = 0; + tWidgetClick(a.btn); + tKeySpace(); + tPump(1); + TEST_EQ(sClicks, 2); + tKeyTab(); + TEST_PTR_EQ(wgtGetFocused(), a.input); + tKeyShiftTab(); + TEST_PTR_EQ(wgtGetFocused(), a.btn); + tKeyEnter(); + tPump(1); + TEST_EQ(sClicks, 3); + tKeyEsc(); + tFKey(FKEY_F5); + tKey(KEY_SCAN_DOWN, 0, 0); + tKeyBackspace(); + TEST_EQ(sClicks, 3); + + // Direct key dispatch reaches the focused widget only (Tab focus + // navigation lives in the app layer); Space on the button clicks. + tWidgetKey(a.win, KEY_SPACE, 0); + tPump(1); + TEST_EQ(sClicks, 4); + + tSpyFree(&spy); + tShutdown(); +} + + +static void caseSpies(void) { + tCase("spy ordering"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + FixtureT f = buildFixture("Spies"); + TSpyT spy; + int32_t sx = 0; + int32_t sy = 0; + + tSpyInit(&spy); + TEST_TRUE(tSpyWidget(&spy, f.btn, "onMouseDown")); + TEST_TRUE(tSpyWidget(&spy, f.btn, "onMouseUp")); + TEST_TRUE(tSpyWidget(&spy, f.btn, "onClick")); + TEST_TRUE(!tSpyWidget(&spy, f.btn, "onClick")); + TEST_TRUE(!tSpyWidget(&spy, f.btn, "noSuchSlot")); + TEST_TRUE(tSpyWidget(&spy, f.input, "onFocus")); + TEST_TRUE(tSpyWidget(&spy, f.btn, "onBlur")); + TEST_TRUE(tSpyWidget(&spy, f.label, "onMouseDown")); + TEST_TRUE(tSpyWidget(&spy, f.label, "onMouseUp")); + + // A pressable widget captures the press as a drag; the drag-end path + // still dispatches the release edge, so a button sees onMouseDown, + // onMouseUp and then onClick, in that order. + sClicks = 0; + tWidgetCenter(f.btn, &sx, &sy); + tClick(sx, sy); + TEST_EQ(sClicks, 1); + TEST_STR_EQ(tSpyLog(&spy), "btn.onMouseDown,btn.onMouseUp,btn.onClick"); + TEST_TRUE(tSpyOrder(&spy, "btn.onMouseDown,btn.onMouseUp,btn.onClick")); + TEST_EQ(tSpyCount(&spy, "onClick"), 1); + TEST_EQ(tSpyCount(&spy, "btn.onClick"), 1); + TEST_EQ(spy.entries[0].args[0], MOUSE_LEFT); + + // A plain widget gets both edges with content-relative coordinates; + // the press also blurs the previously focused button in between. + tSpyClear(&spy); + tWidgetCenter(f.label, &sx, &sy); + tClick(sx, sy); + TEST_STR_EQ(tSpyLog(&spy), "lbl.onMouseDown,btn.onBlur,lbl.onMouseUp"); + TEST_EQ(spy.entries[2].argCount, 3); + TEST_EQ(spy.entries[2].args[1], f.label->w / 2); + + tSpyClear(&spy); + tWidgetCenter(f.input, &sx, &sy); + tClick(sx, sy); + TEST_STR_EQ(tSpyLog(&spy), "btn.onBlur,input.onFocus"); + + // Destroying the spied widget drops its hooks without touching the log. + wgtDestroy(f.btn); + tPump(1); + TEST_EQ(tSpyCount(&spy, "onBlur"), 1); + + tSpyFree(&spy); + tShutdown(); +} + + +static void caseTimer(void) { + tCase("timer widget on the fake clock"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + FixtureT f = buildFixture("Timer"); + + sTimerTicks = 0; + wgtTimerStart(f.timer); + tPump(SETTLE_FRAMES); + TEST_EQ(sTimerTicks, 0); + + for (int32_t i = 0; i < TIMER_FIRES; i++) { + tAdvanceClock(TIMER_INTERVAL_MS); + } + + TEST_EQ(sTimerTicks, TIMER_FIRES); + tAdvanceClock(TIMER_INTERVAL_MS - 1); + TEST_EQ(sTimerTicks, TIMER_FIRES); + tShutdown(); +} + + +static void caseTyping(void) { + tCase("typed text reaches a text input"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + FixtureT f = buildFixture("Typing"); + int32_t sx = 0; + int32_t sy = 0; + + tWidgetCenter(f.input, &sx, &sy); + tClick(sx, sy); + TEST_PTR_EQ(wgtGetFocused(), f.input); + tType("Hello, DVX 42!"); + TEST_STR_EQ(wgtGetText(f.input), "Hello, DVX 42!"); + tKeyBackspace(); + TEST_STR_EQ(wgtGetText(f.input), "Hello, DVX 42"); + tShutdown(); +} + + +static void dialogScript(AppContextT *ctx, WindowT *modal, void *userCtx) { + int32_t *seen = (int32_t *)userCtx; + + (void)ctx; + (*seen)++; + tDialogClickButton(modal, "OK"); +} + + +static void onClickCount(WidgetT *w) { + (void)w; + sClicks++; +} + + +static void onTimerTick(WidgetT *w) { + (void)w; + sTimerTicks++; +} + + +static bool predClicked(void *userCtx) { + return *(int32_t *)userCtx >= 3; +} + + +int main(void) { + platformSetLogPath("selfTest.log"); + tSuiteBegin("dvxtest selfTest"); + + int32_t loaded = tProcessInit(DVXTEST_WIDGET_DIR); + + printf("widgets loaded: %ld\n", (long)loaded); + TEST_TRUE(loaded > 0); + + caseClickPaths(); + caseSpies(); + caseTyping(); + caseKeys(); + caseTimer(); + caseDialog(); + caseGolden(); + caseFixtureFiles(); + caseCycleInit(); + + return tSuiteEnd(); +} diff --git a/src/test/dvxtest/staticsAllowlist.txt b/src/test/dvxtest/staticsAllowlist.txt new file mode 100644 index 0000000..480d13b --- /dev/null +++ b/src/test/dvxtest/staticsAllowlist.txt @@ -0,0 +1,10 @@ +# Process-lifetime file-scope variables in libdvx that the host test +# runner must NOT reset between tInit cycles (see checkStatics.sh). +# Widget class/api/iface registries: class ids are index-based and the +# widget .so files are loaded once per process. +widgetClassTable +sApiMap +sIfaceMap +# Monotonic widget-destroy generation: snapshots taken before a reset +# must never compare equal afterwards, so it is deliberately not zeroed. +sWidgetGen diff --git a/src/test/formrt/Makefile b/src/test/formrt/Makefile new file mode 100644 index 0000000..d6c6ff5 --- /dev/null +++ b/src/test/formrt/Makefile @@ -0,0 +1,71 @@ +# 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. + + +# Form-runtime test suite (T5): formrt + basrt on the host backend. +# +# make -C src/test/formrt # build + run (SAN=1 default) +# make -C src/test/formrt SAN=0 +# +# Links obj/host/basrt.a (compiler + runtime + formrt, built by the +# dvxbasic Makefile's host-basrt target) with libdvx.a, libdvxtest.a +# and the widget .so files. + +.DEFAULT_GOAL := all + +include ../dvxtest/dvxtest.mk + +DVXBASIC = $(DVXTEST_ROOT)/src/apps/kpunch/dvxbasic +BASRT_LIB = $(DVXTEST_OBJDIR)/basrt.a +BINDIR = $(DVXTEST_BINDIR)/formrt +TARGET = $(BINDIR)/testFormRt +STUBS_SO = $(BINDIR)/frtStubs.so +SRCS = testFormRt.c frtHarness.c +OBJS = $(patsubst %.c,$(BINDIR)/%.o,$(SRCS)) +CFLAGS = $(DVXTEST_CFLAGS) -I$(DVXBASIC) -I$(DVXTEST_ROOT)/src/libs/kpunch/dvxshell -DFRT_FIXTURE_DIR='"$(abspath .)/fixtures"' -DFRT_STUBS_SO='"$(abspath $(STUBS_SO))"' + +.PHONY: all run libs clean + +all: run + +libs: + $(MAKE) -f $(DVXTEST_DIR)/dvxtest.mk dvxtest-libs SAN=$(SAN) + $(MAKE) -C $(DVXBASIC) host-basrt SAN=$(SAN) + +BAS_HDRS = $(wildcard $(DVXBASIC)/*.h $(DVXBASIC)/*/*.h) + +$(BINDIR)/%.o: %.c frtHarness.h frtStubs.h $(BAS_HDRS) | libs + @mkdir -p $(BINDIR) + $(HOSTCC) $(CFLAGS) -c -o $@ $< + +$(STUBS_SO): frtStubs.c frtStubs.h | libs + @mkdir -p $(BINDIR) + $(HOSTCC) $(CFLAGS) -fPIC -shared -o $@ frtStubs.c + +$(TARGET): $(OBJS) $(STUBS_SO) | libs + $(HOSTCC) -o $@ $(OBJS) $(BASRT_LIB) $(DVXTEST_LINK) + +run: $(TARGET) + ASAN_OPTIONS=detect_leaks=1 $(TARGET) + +clean: + rm -rf $(BINDIR) diff --git a/src/test/formrt/dvx.log b/src/test/formrt/dvx.log new file mode 100644 index 0000000..dd5dab1 --- /dev/null +++ b/src/test/formrt/dvx.log @@ -0,0 +1,2638 @@ + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuChk + Property: Checked + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuChk + Property: Checked + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd1 + Type: CommandButton + Property: NoSuchProperty + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Method not found on control + Method: Frobnicate + Control: Lst + The control has no method by that name. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only expose Checked, Enabled, and Name. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Name + Valid: Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Unknown form property + Form: Form1 + Property: Bogus + Valid: Name, Caption, Width, Height, Left, Top, Visible, Resizable, AutoSize, Centered, Layout, ContextMenu, HelpTopic. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no readable property by that name. + +BASIC RUNTIME ERROR: Property not found on control + Control: Cmd + Type: CommandButton + Property: Bogus + The control has no writable property by that name. + +BASIC RUNTIME ERROR: Read of unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: mnuPopA + Property: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on unknown control + Control: mnuPopA + Method: X + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Method call on control with no widget + Method: SetFocus + Control: mnuOpen + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Unknown menu property + Menu: mnuOpen + Property: Wat + Menu items only support Checked and Enabled. + +BASIC RUNTIME ERROR: Bad enum value for property + Property: Alignment + Value: Wat + Not one of this property's named values. + +BASIC RUNTIME ERROR: Property read on control with no widget + Control: mnuPopA + Property: Checked + The control is known but has no attached widget. + +BASIC RUNTIME ERROR: Show on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: Hide on form with no window + Form: Ghost + The form has no window attached (was it destroyed already?). + +BASIC RUNTIME ERROR: CreateControl: form has no parent widget + Form: Ghost + Type: TextBox + Control name: G1 + +BASIC RUNTIME ERROR: Method not found on control + Method: NoSuchMethod + Control: Form1 + The control has no method by that name. + +BASIC RUNTIME ERROR: CreateControl: unknown control type + Requested type: Sprocket + Form: Form2 + Control name: S1 + +BASIC RUNTIME ERROR: CreateControl: form reference is NULL + Type: TextBox + Control name: T2 + +BASIC RUNTIME ERROR: Show on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Hide on unknown form + The form reference resolved to NULL; check that the form name exists. + +BASIC RUNTIME ERROR: Assignment to unknown control + Control: NoSuchCtrl + Property: Text + Control was not found on any loaded form. + +BASIC RUNTIME ERROR: Unknown widget type in form + Form: FormU + Control: S1 + Type: Sprocket + Not a recognized widget type (check spelling; DVX uses VB6-style names, e.g. SpinButton not Spinner). + +BASIC RUNTIME ERROR: Bad property value in form + Form: FormE + Control: L + Property: Alignment + Value: Wat + Not a valid value for this property. diff --git a/src/test/formrt/fixtures/icon.bmp b/src/test/formrt/fixtures/icon.bmp new file mode 100644 index 0000000..2ff3d44 --- /dev/null +++ b/src/test/formrt/fixtures/icon.bmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e80c12d82bec065b279d219f657e0475ebbcf55dce2bb3b916aa202671b37032 +size 3126 diff --git a/src/test/formrt/frtHarness.c b/src/test/formrt/frtHarness.c new file mode 100644 index 0000000..786b57e --- /dev/null +++ b/src/test/formrt/frtHarness.c @@ -0,0 +1,567 @@ +// 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. + + +// frtHarness.c -- form-runtime test harness (suite T5) + +#include "frtHarness.h" +#include "../../apps/kpunch/dvxbasic/basRes.h" +#include "../../apps/kpunch/dvxbasic/compiler/parser.h" +#include "thirdparty/stb_ds_wrap.h" + +#include +#include +#include +#include +#include + +#define FRT_INDENT_SPACES 4 +#define FRT_INDENT_MAX 64 +#define FRT_VALUE_MAX 512 +#define FRT_DIRECTIVE_MAX 128 +#define FRT_PROBE_SIZE 200 +#define FRT_PROBE_NAME_MAX 32 + + +typedef struct { + char name[FRT_PROBE_NAME_MAX]; + bool keeps; +} FrtTextProbeT; + + +static BasControlT *ctrlForWidget(const BasFormT *form, const WidgetT *w); +static int32_t emitCtrl(const BasFormT *form, const BasControlT *ctrl, char *buf, int32_t bufSize, int32_t pos, int32_t indent); +static int32_t emitFormHeader(const BasFormT *form, char *buf, int32_t bufSize, int32_t pos); +static int32_t emitLine(char *buf, int32_t bufSize, int32_t pos, int32_t indent, const char *fmt, ...); +static int32_t emitPropValue(char *buf, int32_t bufSize, int32_t pos, int32_t indent, const char *name, uint8_t type, BasValueT value); +static int32_t emitSubtree(const BasFormT *form, const WidgetT *parent, char *buf, int32_t bufSize, int32_t pos, int32_t indent); +static void harnessDismissDialog(AppContextT *ctx, WindowT *modal, void *userCtx); +static bool harnessDoEvents(void *ctx); +static bool harnessInput(void *ctx, const char *prompt, char *buf, int32_t bufSize); +static void harnessPrint(void *ctx, const char *text, bool newline); + + +// Shared common properties the round trip writes for every control, +// in emission order. Geometry (Left/Top/Width/Height/MinWidth/ +// MinHeight) is excluded: the layout engine owns those at runtime. +static const char *const sRoundTripCommon[] = { + "Weight", + "MaxWidth", + "MaxHeight", + "Visible", + "Enabled", + "ReadOnly", + "BackColor", + "ForeColor", + "ToolTipText", + "HelpTopic", + "ContextMenu", + "DataSource", + "DataField", +}; + +static FrtTextProbeT *sTextProbes = NULL; // stb_ds array, lives for the process + +#define FRT_ROUND_TRIP_COMMON_COUNT ((int32_t)(sizeof(sRoundTripCommon) / sizeof(sRoundTripCommon[0]))) + + +static BasControlT *ctrlForWidget(const 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; +} + + +static int32_t emitCtrl(const BasFormT *form, const BasControlT *ctrl, char *buf, int32_t bufSize, int32_t pos, int32_t indent) { + BasFormRtT *rt = form->vm ? (BasFormRtT *)form->vm->ui.ctx : NULL; + WidgetT *w = ctrl->widget; + + pos = emitLine(buf, bufSize, pos, indent, "Begin %s %s\n", ctrl->typeName, ctrl->name); + + if (ctrl->index >= 0) { + pos = emitLine(buf, bufSize, pos, indent + 1, "Index = %d\n", (int)ctrl->index); + } + + const char *text = wgtGetText(w); + const char *typeName = wgtFindByBasName(ctrl->typeName); + + if (text && text[0] && typeName && frtWidgetKeepsText(typeName)) { + pos = emitLine(buf, bufSize, pos, indent + 1, "Caption = \"%s\"\n", text); + } + + if (ctrl->iface) { + for (int32_t i = 0; i < ctrl->iface->propCount; i++) { + const WgtPropDescT *p = &ctrl->iface->props[i]; + char val[FRT_VALUE_MAX]; + + if (!p->getFn || !p->setFn) { + continue; + } + + if (!wgtPropValueToString(w, p, val, sizeof(val))) { + continue; + } + + if (p->type == WGT_IFACE_STRING) { + pos = emitLine(buf, bufSize, pos, indent + 1, "%s = \"%s\"\n", p->name, val); + } else { + pos = emitLine(buf, bufSize, pos, indent + 1, "%s = %s\n", p->name, val); + } + } + } + + for (int32_t i = 0; i < FRT_ROUND_TRIP_COMMON_COUNT; i++) { + const char *name = sRoundTripCommon[i]; + const BasPropDescT *pd = basFormRtFindCommonProp(name); + + if (!pd || (ctrl->iface && wgtIfaceFindProp(ctrl->iface, name))) { + continue; + } + + BasValueT v = basFormRtGetProp(rt, (void *)ctrl, name); + + pos = emitPropValue(buf, bufSize, pos, indent + 1, name, pd->type, v); + basValRelease(&v); + } + + if (ctrl->iface && ctrl->iface->isContainer) { + pos = emitSubtree(form, w, buf, bufSize, pos, indent + 1); + } + + pos = emitLine(buf, bufSize, pos, indent, "End\n"); + return pos; +} + + +static int32_t emitFormHeader(const BasFormT *form, char *buf, int32_t bufSize, int32_t pos) { + BasFormRtT *rt = form->vm ? (BasFormRtT *)form->vm->ui.ctx : NULL; + int32_t count = 0; + const BasPropDescT *props = basFormRtFormProps(&count); + + pos = emitLine(buf, bufSize, pos, 0, "VERSION DVX 1.00\n"); + pos = emitLine(buf, bufSize, pos, 0, "Begin Form %s\n", form->name); + + for (int32_t i = 0; i < count; i++) { + const BasPropDescT *pd = &props[i]; + + if (strcasecmp(pd->name, "Name") == 0 || strcasecmp(pd->name, "Visible") == 0) { + continue; + } + + if (form->frmCentered && (strcasecmp(pd->name, "Left") == 0 || strcasecmp(pd->name, "Top") == 0)) { + continue; + } + + if (form->frmAutoSize && (strcasecmp(pd->name, "Width") == 0 || strcasecmp(pd->name, "Height") == 0)) { + continue; + } + + BasValueT v = basFormRtGetProp(rt, (void *)&form->formCtrl, pd->name); + + if (pd->type == WGT_IFACE_STRING && (strcasecmp(pd->name, "HelpTopic") == 0 || strcasecmp(pd->name, "ContextMenu") == 0) && (!v.strVal || v.strVal->len == 0)) { + basValRelease(&v); + continue; + } + + pos = emitPropValue(buf, bufSize, pos, 1, pd->name, pd->type, v); + basValRelease(&v); + } + + return pos; +} + + +static int32_t emitLine(char *buf, int32_t bufSize, int32_t pos, int32_t indent, const char *fmt, ...) { + if (pos >= bufSize - 1) { + return bufSize - 1; + } + + int32_t pad = indent * FRT_INDENT_SPACES; + + if (pad > FRT_INDENT_MAX) { + pad = FRT_INDENT_MAX; + } + + if (pad > bufSize - 1 - pos) { + pad = bufSize - 1 - pos; + } + + memset(buf + pos, ' ', pad); + pos += pad; + + va_list ap; + + va_start(ap, fmt); + + int32_t n = vsnprintf(buf + pos, bufSize - pos, fmt, ap); + + va_end(ap); + + if (n < 0) { + return pos; + } + + pos += n; + + if (pos > bufSize - 1) { + pos = bufSize - 1; + } + + return pos; +} + + +static int32_t emitPropValue(char *buf, int32_t bufSize, int32_t pos, int32_t indent, const char *name, uint8_t type, BasValueT value) { + switch (type) { + case WGT_IFACE_STRING: { + BasStringT *s = basValFormatString(value); + + pos = emitLine(buf, bufSize, pos, indent, "%s = \"%s\"\n", name, s->data); + basStringUnref(s); + break; + } + + case WGT_IFACE_BOOL: + pos = emitLine(buf, bufSize, pos, indent, "%s = %s\n", name, basValIsTruthy(value) ? "True" : "False"); + break; + + default: + pos = emitLine(buf, bufSize, pos, indent, "%s = %d\n", name, (int)basValToNumber(value)); + break; + } + + return pos; +} + + +static int32_t emitSubtree(const BasFormT *form, const WidgetT *parent, char *buf, int32_t bufSize, int32_t pos, int32_t indent) { + for (const WidgetT *c = parent->firstChild; c; c = c->nextSibling) { + const BasControlT *ctrl = ctrlForWidget(form, c); + + if (ctrl) { + pos = emitCtrl(form, ctrl, buf, bufSize, pos, indent); + } else { + // Layout box inserted by a container's Layout property: + // transparent, its children belong to the container. + pos = emitSubtree(form, c, buf, bufSize, pos, indent); + } + } + + return pos; +} + + +bool frtBuild(FrtAppT *app, const char *basSource, const char *const *frms, int32_t frmCount) { + memset(app, 0, sizeof(*app)); + + char *src = (char *)calloc(1, FRT_SRC_MAX); + int32_t pos = 0; + + if (!src) { + snprintf(app->error, sizeof(app->error), "out of memory"); + return false; + } + + for (int32_t i = 0; i < frmCount; i++) { + char formName[BAS_MAX_IDENT] = ""; + int32_t len = (int32_t)strlen(frms[i]); + + basExtractFormName(frms[i], formName, BAS_MAX_IDENT); + + const char *code = frms[i] + basFindFormEndPos(frms[i], len); + + pos += snprintf(src + pos, FRT_SRC_MAX - pos, "BEGINFORM \"%s\"\n%s\nENDFORM\n", formName, code); + } + + pos += snprintf(src + pos, FRT_SRC_MAX - pos, "%s\n", basSource ? basSource : ""); + + BasParserT parser; + + basParserInit(&parser, src, pos); + + if (!basParse(&parser)) { + snprintf(app->error, sizeof(app->error), "%s", parser.error); + basParserFree(&parser); + free(src); + return false; + } + + app->module = basParserBuildModule(&parser); + basParserFree(&parser); + free(src); + + if (!app->module) { + snprintf(app->error, sizeof(app->error), "module build failed"); + return false; + } + + app->vm = basVmCreate(); + basVmLoadModule(app->vm, app->module); + basVmSetPrintCallback(app->vm, harnessPrint, app); + basVmSetInputCallback(app->vm, harnessInput, app); + basVmSetDoEventsCallback(app->vm, harnessDoEvents, app); + + BasExternCallbacksT extCb; + + memset(&extCb, 0, sizeof(extCb)); + extCb.resolveExtern = basExternResolve; + extCb.callExtern = basExternCall; + basVmSetExternCallbacks(app->vm, &extCb); + + snprintf(app->vm->appPath, DVX_MAX_PATH, "%s", tTempDir()); + snprintf(app->vm->appConfig, DVX_MAX_PATH, "%s/CONFIG", tTempDir()); + snprintf(app->vm->appData, DVX_MAX_PATH, "%s/DATA", tTempDir()); + + app->rt = basFormRtCreate(tCtx(), app->vm, app->module); + + // Unexpected modal dialogs (runtime errors) are dismissed instead of + // blocking the suite; a case that expects one installs its own + // script after frtBuild. + tDialogScript(harnessDismissDialog, app); + + for (int32_t i = 0; i < frmCount; i++) { + char formName[BAS_MAX_IDENT] = ""; + + basExtractFormName(frms[i], formName, BAS_MAX_IDENT); + + if (formName[0]) { + basFormRtRegisterFrm(app->rt, formName, frms[i], (int32_t)strlen(frms[i])); + } + } + + return true; +} + + +void frtClearError(FrtAppT *app) { + app->rt->terminated = false; + app->vm->errorMsg[0] = '\0'; + app->vm->errorNumber = 0; + app->vm->ended = false; +} + + +BasControlT *frtCtrl(const BasFormT *form, const char *name) { + return frtCtrlIdx(form, name, -1); +} + + +BasControlT *frtCtrlIdx(const BasFormT *form, const char *name, int32_t 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, name) == 0) { + return form->controls[i]; + } + } + + return NULL; +} + + +void frtDestroy(FrtAppT *app) { + tDialogScript(NULL, NULL); + + if (app->rt) { + basFormRtDestroy(app->rt); + app->rt = NULL; + } + + if (app->vm) { + basVmDestroy(app->vm); + app->vm = NULL; + } + + if (app->module) { + basModuleFree(app->module); + app->module = NULL; + } +} + + +bool frtFire(FrtAppT *app, const char *formName, const char *eventName) { + BasFormT *form = frtForm(app, formName); + + if (!form) { + return false; + } + + bool ran = basFormRtFireEvent(app->rt, form, formName, eventName); + + tPump(1); + return ran; +} + + +BasFormT *frtForm(const FrtAppT *app, const char *name) { + if (!app->rt) { + return NULL; + } + + for (int32_t i = 0; i < (int32_t)arrlen(app->rt->forms); i++) { + if (strcasecmp(app->rt->forms[i]->name, name) == 0) { + return app->rt->forms[i]; + } + } + + return NULL; +} + + +const char *frtOut(const FrtAppT *app) { + return app->out; +} + + +void frtOutClear(FrtAppT *app) { + app->out[0] = '\0'; + app->outLen = 0; +} + + +BasVmResultE frtRunMain(FrtAppT *app) { + BasVmResultE result; + + basVmSetStepLimit(app->vm, BAS_VM_DEFAULT_STEP_SLICE); + + do { + result = basVmRun(app->vm); + + if (result == BAS_VM_STEP_LIMIT) { + dvxUpdate(tCtx()); + } + } while (result == BAS_VM_STEP_LIMIT); + + app->mainResult = result; + return result; +} + + +int32_t frtSerializeForm(const BasFormT *form, char *buf, int32_t bufSize) { + int32_t pos = 0; + + if (!form || !buf || bufSize <= 0) { + return -1; + } + + pos = emitFormHeader(form, buf, bufSize, pos); + + if (form->contentBox) { + pos = emitSubtree(form, form->contentBox, buf, bufSize, pos, 1); + } + + pos = emitLine(buf, bufSize, pos, 0, "End\n"); + buf[pos] = '\0'; + return pos; +} + + +void frtStart(FrtAppT *app, const char *startupForm) { + basFormRtLoadAllForms(app->rt, startupForm); + tPump(1); + frtRunMain(app); + tPump(1); +} + + +bool frtWidgetKeepsText(const char *wgtName) { + for (int32_t i = 0; i < (int32_t)arrlen(sTextProbes); i++) { + if (strcasecmp(sTextProbes[i].name, wgtName) == 0) { + return sTextProbes[i].keeps; + } + } + + WindowT *win = dvxCreateWindow(tCtx(), "Probe", 0, 0, FRT_PROBE_SIZE, FRT_PROBE_SIZE, false); + WidgetT *root = wgtInitWindow(tCtx(), win); + WidgetT *w = createWidget(wgtName, root); + bool keeps = false; + + if (w) { + wgtSetText(w, "Probe"); + keeps = strcmp(wgtGetText(w), "Probe") == 0; + } + + dvxDestroyWindow(tCtx(), win); + tPump(1); + + FrtTextProbeT probe; + + memset(&probe, 0, sizeof(probe)); + snprintf(probe.name, sizeof(probe.name), "%s", wgtName); + probe.keeps = keeps; + arrput(sTextProbes, probe); + return keeps; +} + + +static void harnessDismissDialog(AppContextT *ctx, WindowT *modal, void *userCtx) { + FrtAppT *app = (FrtAppT *)userCtx; + + (void)ctx; + app->dialogsDismissed++; + printf(" harness: dismissed dialog [%s]\n", modal->title); + + if (!tDialogClickButton(modal, "OK")) { + tDialogClickButton(modal, "Cancel"); + } +} + + +static bool harnessDoEvents(void *ctx) { + (void)ctx; + return dvxUpdate(tCtx()); +} + + +static bool harnessInput(void *ctx, const char *prompt, char *buf, int32_t bufSize) { + (void)ctx; + (void)prompt; + (void)bufSize; + buf[0] = '\0'; + return false; +} + + +static void harnessPrint(void *ctx, const char *text, bool newline) { + FrtAppT *app = (FrtAppT *)ctx; + + if (text) { + int32_t len = (int32_t)strlen(text); + + if (app->outLen + len < FRT_OUT_MAX - 1) { + memcpy(app->out + app->outLen, text, len); + app->outLen += len; + } + } + + if (newline && app->outLen < FRT_OUT_MAX - 1) { + app->out[app->outLen++] = '\n'; + } + + app->out[app->outLen] = '\0'; +} diff --git a/src/test/formrt/frtHarness.h b/src/test/formrt/frtHarness.h new file mode 100644 index 0000000..19ad8b3 --- /dev/null +++ b/src/test/formrt/frtHarness.h @@ -0,0 +1,100 @@ +// 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. + + +// frtHarness.h -- form-runtime test harness (suite T5) +// +// Compiles BASIC source plus .frm text in-process, stands up a VM and +// a BasFormRtT on the dvxtest display, captures PRINT output, and +// serializes a loaded form back to .frm text from the runtime's own +// view (BasFormT / widget tree / property descriptors). + +#ifndef FRT_HARNESS_H +#define FRT_HARNESS_H + +#include "dvxtest.h" +#include "../../apps/kpunch/dvxbasic/formrt/formrt.h" + +#include +#include + +#define FRT_OUT_MAX 8192 +#define FRT_SRC_MAX 32768 +#define FRT_FRM_MAX 8 + +typedef struct { + BasModuleT *module; + BasVmT *vm; + BasFormRtT *rt; + char out[FRT_OUT_MAX]; // PRINT capture + int32_t outLen; + char error[512]; // parser error text when frtBuild fails + BasVmResultE mainResult; // last frtRunMain result + int32_t dialogsDismissed; // modal dialogs auto-dismissed by the harness +} FrtAppT; + +// Compile basSource together with the code sections of every .frm text +// (each wrapped in BEGINFORM/ENDFORM like bascomp does), create the VM +// and form runtime, and register every .frm for lazy loading. Nothing +// is loaded or run yet. Returns false (with app->error set) when the +// source does not compile. +bool frtBuild(FrtAppT *app, const char *basSource, const char *const *frms, int32_t frmCount); + +// basFormRtLoadAllForms + frtRunMain. +void frtStart(FrtAppT *app, const char *startupForm); + +// Run the module's main code until it halts or errors, pumping the +// display on every step-limit yield. Returns the VM result. +BasVmResultE frtRunMain(FrtAppT *app); + +// Tear everything down (runtime, VM, module). +void frtDestroy(FrtAppT *app); + +// Lookups. +BasControlT *frtCtrl(const BasFormT *form, const char *name); +BasControlT *frtCtrlIdx(const BasFormT *form, const char *name, int32_t index); +BasFormT *frtForm(const FrtAppT *app, const char *name); + +// Fire a named event on a form (handler FormName_EventName) and pump one +// frame. Returns false when no handler ran. +bool frtFire(FrtAppT *app, const char *formName, const char *eventName); + +// PRINT capture. +const char *frtOut(const FrtAppT *app); +void frtOutClear(FrtAppT *app); + +// Serialize form back to .frm text from the runtime's view. Controls +// are walked in widget-tree order; every interface property with a +// getter and a setter, then the shared common properties, are written +// through the same descriptors the loader used. Returns the length. +int32_t frtSerializeForm(const BasFormT *form, char *buf, int32_t bufSize); + +// True when a widget of this type stores text set through wgtSetText +// (containers and value widgets do not). Probed with a scratch window +// on the live context and cached per type name. +bool frtWidgetKeepsText(const char *wgtName); + +// Clear a runtime error left by a negative test so the next statement +// can run: rt->terminated, the VM error text and the halted flags. +void frtClearError(FrtAppT *app); + +#endif // FRT_HARNESS_H diff --git a/src/test/formrt/frtStubs.c b/src/test/formrt/frtStubs.c new file mode 100644 index 0000000..3918185 --- /dev/null +++ b/src/test/formrt/frtStubs.c @@ -0,0 +1,571 @@ +// 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. + + +// frtStubs.c -- fake rs232 / secLink / hlpc / shell backends (test .so) +// +// See frtStubs.h. Function signatures mirror the SerApiT / +// CommSecLinkApiT / HlpcCompileFnT / ShellLoadAppFnT / ShellIdleFnT +// typedefs in formrt.c exactly; formrt resolves them by name with +// dlsym(NULL, ...) once this library is dlopen()ed with RTLD_GLOBAL. + +#include "frtStubs.h" + +#include +#include +#include + +#define STUB_UART_16550A 4 +#define STUB_DEFAULT_BASE 0x3F8 +#define STUB_DEFAULT_IRQ 4 + +typedef struct { + bool open; + uint8_t rx[FRT_STUB_BUF]; + int32_t rxLen; + uint8_t tx[FRT_STUB_BUF]; + int32_t txLen; + int32_t base; + int32_t irq; +} StubPortT; + +typedef struct { + bool open; + void (*recvCb)(void *, const uint8_t *, int32_t, uint8_t); + void *recvCtx; +} StubLinkT; + +typedef struct { + void (*fn)(void *); + void *ctx; +} StubIdleT; + + +// Prototypes (alphabetical; exported symbols formrt.c resolves by name) +const FrtStubApiT *frtStubApi(void); +int32_t hlpcCompile(const char **inputs, int32_t inputCount, const char *outputFile, const char *a, const char *b, int32_t flags, void *x, void *y); +int32_t rs232ClearRxBuffer(int32_t com); +int32_t rs232Close(int32_t com); +int32_t rs232GetBase(int32_t com); +int32_t rs232GetIrq(int32_t com); +int32_t rs232GetRxBuffered(int32_t com); +int32_t rs232GetUartType(int32_t com); +int32_t rs232Open(int32_t com, int32_t bps, int32_t dataBits, char parity, int32_t stopBits, int32_t handshake); +int32_t rs232Read(int32_t com, char *data, int32_t len); +int32_t rs232SetBase(int32_t com, int32_t base); +int32_t rs232SetIrq(int32_t com, int32_t irq); +int32_t rs232Write(int32_t com, const char *data, int32_t len); +int32_t rs232WriteBuf(int32_t com, const char *data, int32_t len); +void secLinkClose(void *link); +int32_t secLinkGetPending(void *link); +int32_t secLinkHandshake(void *link); +bool secLinkIsReady(void *link); +void *secLinkOpen(int32_t com, int32_t bps, int32_t dataBits, char parity, int32_t stopBits, int32_t handshake, void (*recvCb)(void *, const uint8_t *, int32_t, uint8_t), void *recvCtx); +int32_t secLinkPoll(void *link); +int32_t secLinkSend(void *link, const uint8_t *data, int32_t len, uint8_t channel, bool encrypt, bool flush); +int32_t secLinkSendBuf(void *link, const uint8_t *data, int32_t len, uint8_t channel, bool encrypt); +int32_t shellLoadAppWithArgs(void *ctx, const char *appPath, const char *args); +void shellRegisterIdle(void (*fn)(void *), void *ctx); +void shellUnregisterIdle(void (*fn)(void *), void *ctx); + +static void stubCommFail(bool openFails, bool sendFails); +static void stubCommHandshakeRc(int32_t rc); +static void stubCommInject(const void *data, int32_t len, uint8_t channel); +static int32_t stubCommLinks(void); +static const uint8_t *stubCommSent(int32_t *len, uint8_t *channel, bool *encrypted); +static const char *stubHlpcLastOutput(void); +static void stubHlpcRc(int32_t rc); +static int32_t stubIdleCount(void); +static void stubIdleRun(void); +static void stubReset(void); +static void stubSerFail(bool openFails, bool writeFails); +static bool stubSerIsOpen(int32_t com); +static void stubSerPush(int32_t com, const void *data, int32_t len); +static const uint8_t *stubSerTx(int32_t com, int32_t *len); +static const char *stubShellLastApp(void); +static const char *stubShellLastArgs(void); + + +static StubPortT sStubPorts[FRT_STUB_PORTS]; +static bool sStubSerOpenFails = false; +static bool sStubSerWriteFails = false; + +static StubLinkT sStubLinks[FRT_STUB_MAX_LINKS]; +static bool sStubCommOpenFails = false; +static bool sStubCommSendFails = false; +static int32_t sStubHandshakeRc = 0; +static uint8_t sStubSent[FRT_STUB_BUF]; +static int32_t sStubSentLen = 0; +static uint8_t sStubSentChannel = 0; +static bool sStubSentEncrypted = false; + +static StubIdleT sStubIdle[FRT_STUB_MAX_IDLE]; +static int32_t sStubIdleCount = 0; + +static int32_t sStubHlpcRc = 0; +static char sStubHlpcOutput[FRT_STUB_PATH_MAX]; +static char sStubShellApp[FRT_STUB_PATH_MAX]; +static char sStubShellArgs[FRT_STUB_PATH_MAX]; + +static const FrtStubApiT sStubApi = { + .reset = stubReset, + .serPush = stubSerPush, + .serTx = stubSerTx, + .serFail = stubSerFail, + .serIsOpen = stubSerIsOpen, + .commFail = stubCommFail, + .commInject = stubCommInject, + .commLinks = stubCommLinks, + .commSent = stubCommSent, + .commHandshakeRc = stubCommHandshakeRc, + .idleRun = stubIdleRun, + .idleCount = stubIdleCount, + .hlpcRc = stubHlpcRc, + .hlpcLastOutput = stubHlpcLastOutput, + .shellLastApp = stubShellLastApp, + .shellLastArgs = stubShellLastArgs, +}; + + +const FrtStubApiT *frtStubApi(void) { + return &sStubApi; +} + + +int32_t hlpcCompile(const char **inputs, int32_t inputCount, const char *outputFile, const char *a, const char *b, int32_t flags, void *x, void *y) { + (void)a; + (void)b; + (void)flags; + (void)x; + (void)y; + + if (!inputs || inputCount < 1 || !outputFile) { + return -1; + } + + snprintf(sStubHlpcOutput, sizeof(sStubHlpcOutput), "%s", outputFile); + return sStubHlpcRc; +} + + +int32_t rs232ClearRxBuffer(int32_t com) { + if (com < 0 || com >= FRT_STUB_PORTS) { + return -1; + } + + sStubPorts[com].rxLen = 0; + return 0; +} + + +int32_t rs232Close(int32_t com) { + if (com < 0 || com >= FRT_STUB_PORTS) { + return -1; + } + + sStubPorts[com].open = false; + return 0; +} + + +int32_t rs232GetBase(int32_t com) { + if (com < 0 || com >= FRT_STUB_PORTS) { + return 0; + } + + return sStubPorts[com].base ? sStubPorts[com].base : STUB_DEFAULT_BASE; +} + + +int32_t rs232GetIrq(int32_t com) { + if (com < 0 || com >= FRT_STUB_PORTS) { + return 0; + } + + return sStubPorts[com].irq ? sStubPorts[com].irq : STUB_DEFAULT_IRQ; +} + + +int32_t rs232GetRxBuffered(int32_t com) { + if (com < 0 || com >= FRT_STUB_PORTS) { + return 0; + } + + return sStubPorts[com].rxLen; +} + + +int32_t rs232GetUartType(int32_t com) { + (void)com; + return STUB_UART_16550A; +} + + +int32_t rs232Open(int32_t com, int32_t bps, int32_t dataBits, char parity, int32_t stopBits, int32_t handshake) { + (void)bps; + (void)dataBits; + (void)parity; + (void)stopBits; + (void)handshake; + + if (com < 0 || com >= FRT_STUB_PORTS || sStubSerOpenFails) { + return -1; + } + + sStubPorts[com].open = true; + return 0; +} + + +int32_t rs232Read(int32_t com, char *data, int32_t len) { + if (com < 0 || com >= FRT_STUB_PORTS || !data || len <= 0) { + return 0; + } + + StubPortT *port = &sStubPorts[com]; + int32_t n = port->rxLen < len ? port->rxLen : len; + + memcpy(data, port->rx, n); + memmove(port->rx, port->rx + n, port->rxLen - n); + port->rxLen -= n; + return n; +} + + +int32_t rs232SetBase(int32_t com, int32_t base) { + if (com >= 0 && com < FRT_STUB_PORTS) { + sStubPorts[com].base = base; + } + + return 0; +} + + +int32_t rs232SetIrq(int32_t com, int32_t irq) { + if (com >= 0 && com < FRT_STUB_PORTS) { + sStubPorts[com].irq = irq; + } + + return 0; +} + + +int32_t rs232Write(int32_t com, const char *data, int32_t len) { + return rs232WriteBuf(com, data, len); +} + + +int32_t rs232WriteBuf(int32_t com, const char *data, int32_t len) { + if (com < 0 || com >= FRT_STUB_PORTS || sStubSerWriteFails) { + return -1; + } + + StubPortT *port = &sStubPorts[com]; + int32_t room = FRT_STUB_BUF - port->txLen; + int32_t n = len < room ? len : room; + + memcpy(port->tx + port->txLen, data, n); + port->txLen += n; + return n; +} + + + + +void secLinkClose(void *link) { + StubLinkT *l = (StubLinkT *)link; + + if (l) { + l->open = false; + l->recvCb = NULL; + } +} + + +int32_t secLinkGetPending(void *link) { + (void)link; + return 0; +} + + +int32_t secLinkHandshake(void *link) { + (void)link; + return sStubHandshakeRc; +} + + +bool secLinkIsReady(void *link) { + StubLinkT *l = (StubLinkT *)link; + + return l && l->open; +} + + +void *secLinkOpen(int32_t com, int32_t bps, int32_t dataBits, char parity, int32_t stopBits, int32_t handshake, void (*recvCb)(void *, const uint8_t *, int32_t, uint8_t), void *recvCtx) { + (void)com; + (void)bps; + (void)dataBits; + (void)parity; + (void)stopBits; + (void)handshake; + + if (sStubCommOpenFails) { + return NULL; + } + + for (int32_t i = 0; i < FRT_STUB_MAX_LINKS; i++) { + if (!sStubLinks[i].open) { + sStubLinks[i].open = true; + sStubLinks[i].recvCb = recvCb; + sStubLinks[i].recvCtx = recvCtx; + return &sStubLinks[i]; + } + } + + return NULL; +} + + +int32_t secLinkPoll(void *link) { + (void)link; + return 0; +} + + +int32_t secLinkSend(void *link, const uint8_t *data, int32_t len, uint8_t channel, bool encrypt, bool flush) { + (void)flush; + return secLinkSendBuf(link, data, len, channel, encrypt); +} + + +int32_t secLinkSendBuf(void *link, const uint8_t *data, int32_t len, uint8_t channel, bool encrypt) { + StubLinkT *l = (StubLinkT *)link; + + if (!l || !l->open || sStubCommSendFails) { + return -1; + } + + sStubSentLen = len < FRT_STUB_BUF ? len : FRT_STUB_BUF; + memcpy(sStubSent, data, sStubSentLen); + sStubSentChannel = channel; + sStubSentEncrypted = encrypt; + + // Loop the payload straight back into the link's receive callback, + // covering the runtime's channel ring buffer path. + if (l->recvCb) { + l->recvCb(l->recvCtx, data, len, channel); + } + + return 0; +} + + +int32_t shellLoadAppWithArgs(void *ctx, const char *appPath, const char *args) { + (void)ctx; + snprintf(sStubShellApp, sizeof(sStubShellApp), "%s", appPath ? appPath : ""); + snprintf(sStubShellArgs, sizeof(sStubShellArgs), "%s", args ? args : ""); + return 0; +} + + +void shellRegisterIdle(void (*fn)(void *), void *ctx) { + for (int32_t i = 0; i < sStubIdleCount; i++) { + if (sStubIdle[i].fn == fn && sStubIdle[i].ctx == ctx) { + return; + } + } + + if (sStubIdleCount < FRT_STUB_MAX_IDLE) { + sStubIdle[sStubIdleCount].fn = fn; + sStubIdle[sStubIdleCount].ctx = ctx; + sStubIdleCount++; + } +} + + + + +void shellUnregisterIdle(void (*fn)(void *), void *ctx) { + for (int32_t i = 0; i < sStubIdleCount; i++) { + if (sStubIdle[i].fn == fn && sStubIdle[i].ctx == ctx) { + sStubIdle[i] = sStubIdle[sStubIdleCount - 1]; + sStubIdleCount--; + return; + } + } +} + + + + +static void stubCommFail(bool openFails, bool sendFails) { + sStubCommOpenFails = openFails; + sStubCommSendFails = sendFails; +} + + +static void stubCommHandshakeRc(int32_t rc) { + sStubHandshakeRc = rc; +} + + +static void stubCommInject(const void *data, int32_t len, uint8_t channel) { + for (int32_t i = 0; i < FRT_STUB_MAX_LINKS; i++) { + if (sStubLinks[i].open && sStubLinks[i].recvCb) { + sStubLinks[i].recvCb(sStubLinks[i].recvCtx, (const uint8_t *)data, len, channel); + } + } +} + + +static int32_t stubCommLinks(void) { + int32_t count = 0; + + for (int32_t i = 0; i < FRT_STUB_MAX_LINKS; i++) { + if (sStubLinks[i].open) { + count++; + } + } + + return count; +} + + +static const uint8_t *stubCommSent(int32_t *len, uint8_t *channel, bool *encrypted) { + if (len) { + *len = sStubSentLen; + } + + if (channel) { + *channel = sStubSentChannel; + } + + if (encrypted) { + *encrypted = sStubSentEncrypted; + } + + return sStubSent; +} + + +static const char *stubHlpcLastOutput(void) { + return sStubHlpcOutput; +} + + +static void stubHlpcRc(int32_t rc) { + sStubHlpcRc = rc; +} + + +static int32_t stubIdleCount(void) { + return sStubIdleCount; +} + + +static void stubIdleRun(void) { + // Snapshot: a handler may unregister itself while running. + StubIdleT copy[FRT_STUB_MAX_IDLE]; + int32_t count = sStubIdleCount; + + memcpy(copy, sStubIdle, sizeof(copy)); + + for (int32_t i = 0; i < count; i++) { + copy[i].fn(copy[i].ctx); + } +} + + +static void stubReset(void) { + memset(sStubPorts, 0, sizeof(sStubPorts)); + memset(sStubLinks, 0, sizeof(sStubLinks)); + memset(sStubIdle, 0, sizeof(sStubIdle)); + sStubSerOpenFails = false; + sStubSerWriteFails = false; + sStubCommOpenFails = false; + sStubCommSendFails = false; + sStubHandshakeRc = 0; + sStubSentLen = 0; + sStubSentChannel = 0; + sStubSentEncrypted = false; + sStubIdleCount = 0; + sStubHlpcRc = 0; + sStubHlpcOutput[0] = '\0'; + sStubShellApp[0] = '\0'; + sStubShellArgs[0] = '\0'; +} + + +static void stubSerFail(bool openFails, bool writeFails) { + sStubSerOpenFails = openFails; + sStubSerWriteFails = writeFails; +} + + +static bool stubSerIsOpen(int32_t com) { + if (com < 0 || com >= FRT_STUB_PORTS) { + return false; + } + + return sStubPorts[com].open; +} + + +static void stubSerPush(int32_t com, const void *data, int32_t len) { + if (com < 0 || com >= FRT_STUB_PORTS) { + return; + } + + StubPortT *port = &sStubPorts[com]; + int32_t room = FRT_STUB_BUF - port->rxLen; + int32_t n = len < room ? len : room; + + memcpy(port->rx + port->rxLen, data, n); + port->rxLen += n; +} + + +static const uint8_t *stubSerTx(int32_t com, int32_t *len) { + if (com < 0 || com >= FRT_STUB_PORTS) { + if (len) { + *len = 0; + } + + return NULL; + } + + if (len) { + *len = sStubPorts[com].txLen; + } + + return sStubPorts[com].tx; +} + + +static const char *stubShellLastApp(void) { + return sStubShellApp; +} + + +static const char *stubShellLastArgs(void) { + return sStubShellArgs; +} diff --git a/src/test/formrt/frtStubs.h b/src/test/formrt/frtStubs.h new file mode 100644 index 0000000..2c16a1e --- /dev/null +++ b/src/test/formrt/frtStubs.h @@ -0,0 +1,86 @@ +// 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. + + +// frtStubs.h -- control interface of the frtStubs.so test library +// +// The shared object exports the rs232*, secLink*, hlpcCompile and +// shell* symbols that formrt.c resolves lazily with dlsym(NULL, ...). +// A test case dlopen()s it with RTLD_GLOBAL when it wants those APIs +// resolvable, drives the fakes through the control struct returned by +// frtStubApi(), and dlclose()s it again so other cases still exercise +// the unresolved paths. The control struct is fetched with +// dlsym(handle, "frtStubApi") -- the suite binary never links the .so. + +#ifndef FRT_STUBS_H +#define FRT_STUBS_H + +#include +#include + +#define FRT_STUB_PORTS 4 +#define FRT_STUB_BUF 8192 +#define FRT_STUB_MAX_IDLE 8 +#define FRT_STUB_MAX_LINKS 8 +#define FRT_STUB_PATH_MAX 512 + +typedef struct { + // Reset every fake to its load-time state. + void (*reset)(void); + + // ---- raw serial (rs232*) ---- + // Push bytes into a port's receive queue (0-based COM index). + void (*serPush)(int32_t com, const void *data, int32_t len); + // Bytes written to the port so far; *len receives the count. + const uint8_t *(*serTx)(int32_t com, int32_t *len); + // Force rs232Open / rs232WriteBuf to fail. + void (*serFail)(bool openFails, bool writeFails); + bool (*serIsOpen)(int32_t com); + + // ---- secLink (Comm*) ---- + // Force secLinkOpen to return NULL / secLinkSendBuf to fail. + void (*commFail)(bool openFails, bool sendFails); + // Deliver bytes to every open link's receive callback on a channel. + void (*commInject)(const void *data, int32_t len, uint8_t channel); + // Number of currently open links. + int32_t (*commLinks)(void); + // Last payload handed to secLinkSend/secLinkSendBuf. + const uint8_t *(*commSent)(int32_t *len, uint8_t *channel, bool *encrypted); + // Result secLinkHandshake returns (0 = success). + void (*commHandshakeRc)(int32_t rc); + + // ---- shell idle registry ---- + // Invoke every registered idle handler once. + void (*idleRun)(void); + int32_t (*idleCount)(void); + + // ---- help hooks ---- + void (*hlpcRc)(int32_t rc); + const char *(*hlpcLastOutput)(void); + const char *(*shellLastApp)(void); + const char *(*shellLastArgs)(void); +} FrtStubApiT; + +// Exported from the .so: returns the control struct. +const FrtStubApiT *frtStubApi(void); + +#endif // FRT_STUBS_H diff --git a/src/test/formrt/testFormRt.c b/src/test/formrt/testFormRt.c new file mode 100644 index 0000000..a304b92 --- /dev/null +++ b/src/test/formrt/testFormRt.c @@ -0,0 +1,4149 @@ +// 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. + + +// testFormRt.c -- suite T5: DVX BASIC form runtime on the host backend +// +// Every case compiles BASIC + .frm text in-process, loads it through +// the real formrt, drives it with dvxtest input and asserts on runtime +// state and PRINT output. + +#include "frtHarness.h" +#include "frtStubs.h" +#include "../../apps/kpunch/dvxbasic/compiler/basEvents.h" +#include "../../apps/kpunch/dvxbasic/compiler/opcodes.h" +#include "thirdparty/stb_ds_wrap.h" +#include "timer/timer.h" + +#include "dvxRes.h" +#include "shellApp.h" +#include "dvxDlg.h" +#include "platform/dvxPlatHost.h" +#include "../../libs/kpunch/serial/seclink/secLink.h" + +#include +#include +#include +#include +#include +#include + +#define SCREEN_W 800 +#define SCREEN_H 600 +#define SCREEN_BPP 32 + +#define FORM_W 400 +#define FORM_H 300 +#define TIMER_INTERVAL_MS 100 +#define TIMER_TICKS 3 +#define RESET_CYCLES 50 +#define FRM_TEXT_MAX 16384 +#define POPUP_OFF_X 5 +#define POPUP_OFF_Y 7 +#define MENU_ID_BASE 10000 + +// New-case constants: the stub UART identity (mirrors frtStubs.c), the +// fake SQL handles and row count, and one-past the resource handle +// limit for the exhaustion probe. +#define SER_STUB_BASE 0x3F8 +#define SER_STUB_IRQ 4 +#define SER_STUB_UART 4 +#define SQL_FAKE_DB 7 +#define SQL_FAKE_RS 42 +#define SQL_FAKE_ROWS 2 +#define RES_HANDLE_PROBE 9 + +// Widget-independent line prefix of every round-trip form. +#define RT_FORM_HEADER \ + "VERSION DVX 1.00\n" \ + "Begin Form Form1\n" \ + " Caption = \"Round Trip\"\n" \ + " Width = 400\n" \ + " Height = 300\n" \ + " Resizable = True\n" \ + " AutoSize = False\n" \ + " Centered = True\n" \ + " Layout = \"VBox\"\n" \ + " HelpTopic = \"formHelp\"\n" + +// Menu block shared by the round-trip forms: a bar menu and a popup so +// ContextMenu has something to bind to. Menus are asserted by state +// (the runtime keeps no name for menu headers), so the serializer +// leaves them out and the comparison starts at the first control. +#define RT_FORM_MENUS \ + " Begin Menu mnuPop\n" \ + " Caption = \"Popup\"\n" \ + " Visible = False\n" \ + " Begin Menu mnuPopItem\n" \ + " Caption = \"Pop Item\"\n" \ + " End\n" \ + " End\n" + +// BASIC-facing entry points live in formrt.c without a header (they +// are reached through DECLARE LIBRARY on DOS). +int32_t basChoiceDialog(const char *title, const char *prompt, const char *items, int32_t defaultIdx); +const char *basFileOpen(const char *title, const char *filter); +const char *basFileSave(const char *title, const char *filter); +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); +int32_t basPromptSave(const char *title); +void CommAttach(int32_t handle, const char *termCtrlName, int32_t channel, int32_t encrypt); +void CommClose(int32_t handle); +void CommDetach(int32_t handle); +int32_t CommHandshake(int32_t handle); +int32_t CommIsReady(int32_t handle); +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); +int32_t CommSend(int32_t handle, const char *data, int32_t channel, int32_t encrypt); +int32_t HelpCompile(const char *inputFile, const char *outputFile); +void HelpView(const char *hlpFile); +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); +const char *ResName(int32_t handle, int32_t index); +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); +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); +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); +void SerSetBase(int32_t com, int32_t base); +void SerSetIrq(int32_t com, int32_t irq); +int32_t SerWrite(int32_t com, const char *data); +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); + + +typedef struct { + int32_t seen; + int32_t result; + char title[64]; + char button[16]; +} DialogCtxT; + + +// Modal-dialog driver: type text and accept with OK, or cancel with +// Esc when no text is given. +typedef struct { + const char *typeText; + int32_t seen; + bool busy; // re-entry guard: tType pumps frames, which re-fires the script +} FileDlgCtxT; + + +// frtStubs.so handle + control API (loaded per case, never linked). +static void *sStubHandle = NULL; +static const FrtStubApiT *sStub = NULL; + + +// frmParse unit-test recorder. +typedef struct { + int32_t forms; + int32_t formProps; + int32_t menus; + int32_t menuProps; + int32_t menuEnds; + int32_t ctrls; + int32_t ctrlProps; + int32_t ctrlEnds; + int32_t lastMenuLevel; + char lastType[FRM_MAX_TOKEN_LEN]; + char lastKey[FRM_MAX_TOKEN_LEN]; + char lastVal[FRM_MAX_LINE_LEN]; + char tail[64]; + bool failFormBegin; +} ParseRecT; + + +static BasValueT callIntM(FrtAppT *app, BasControlT *ctrl, const char *method, const int32_t *vals, int32_t count); +static void caseCommonMethods(void); +static void caseControlArrays(void); +static void caseCreateFormDynamic(void); +static void caseDataBinding(void); +static void caseDialogWrappers(void); +static void caseEventLoopExits(void); +static void caseEvents(void); +static void caseExternCalls(void); +static void caseFormShowHide(void); +static void caseFormVars(void); +static void caseFrmLoadEdges(void); +static void caseFrmParserUnit(void); +static void caseHelpHooks(void); +static void caseLoadForm2(void); +static void caseMenus(void); +static void caseMethodSignatures(void); +static void caseMsgBox(void); +static void casePendingUnloadDrain(void); +static void casePopupMenu(void); +static void casePropEdges(void); +static void casePropTables(void); +static void caseQueryUnloadCancel(void); +static void caseRemoveContainerFromHandler(void); +static void caseResetCycles(void); +static void caseResources(void); +static void caseRoundTrip(void); +static void caseRunSimple(void); +static void caseRuntimeError(void); +static void caseSerialLoopback(void); +static void caseSerialUnresolved(void); +static void caseSetEvent(void); +static void caseSqlWrappers(void); +static void caseTimers(void); +static void caseToolbarClearFromHandler(void); +static void caseUnloadDeferred(void); +static void caseValidateCancel(void); +static WidgetT *childAt(WidgetT *w, int32_t idx); +static void dialogClick(AppContextT *ctx, WindowT *modal, void *userCtx); +static bool expectBuild(FrtAppT *app, const char *bas, const char *const *frms, int32_t frmCount); +static void fileDialogType(AppContextT *ctx, WindowT *modal, void *userCtx); +static void outContains(const FrtAppT *app, const char *needle); +static void parseOnCtrlBegin(void *userData, const char *typeName, const char *name); +static void parseOnCtrlEnd(void *userData); +static void parseOnCtrlProp(void *userData, const char *key, const char *value); +static bool parseOnFormBegin(void *userData, const char *name); +static void parseOnFormEnd(void *userData, const char *rest, int32_t restLen); +static void parseOnFormProp(void *userData, const char *key, const char *value); +static void parseOnMenuBegin(void *userData, const char *name, int32_t level); +static void parseOnMenuEnd(void *userData); +static void parseOnMenuProp(void *userData, const char *key, const char *value); +static const char *roundTripValue(const char *basName, const WgtPropDescT *p, char *buf, int32_t bufSize); +static void setStrProp(FrtAppT *app, BasControlT *ctrl, const char *propName, const char *text); +static int32_t sqlFakeAffected(int32_t db); +static void sqlFakeClose(int32_t db); +static bool sqlFakeEof(int32_t rs); +static const char *sqlFakeError(int32_t db); +static bool sqlFakeExec(int32_t db, const char *sql); +static const char *sqlFakeFieldByName(int32_t rs, const char *name); +static int32_t sqlFakeFieldCount(int32_t rs); +static double sqlFakeFieldDbl(int32_t rs, int32_t col); +static int32_t sqlFakeFieldInt(int32_t rs, int32_t col); +static const char *sqlFakeFieldName(int32_t rs, int32_t col); +static const char *sqlFakeFieldText(int32_t rs, int32_t col); +static void sqlFakeFree(int32_t rs); +static bool sqlFakeNext(int32_t rs); +static int32_t sqlFakeOpen(const char *path); +static int32_t sqlFakeQuery(int32_t db, const char *sql); +static void stubsClose(void); +static const FrtStubApiT *stubsOpen(void); +int main(void); + +// Extern-call targets: exported (non-static, -rdynamic) so DECLARE +// LIBRARY declarations resolve them through basExternResolve's +// dlsym(NULL, ...). The host basNativeCall stub never actually calls +// them; only their addresses matter. +double frtExtDbl(double d, float s); +int32_t frtExtFlag(int32_t a); +int32_t frtExtInt(int32_t a); +int32_t frtExtLong(int32_t a, int32_t b); +const char *frtExtStr(const char *s); +void frtExtSub(int32_t a); + + +static BasValueT callIntM(FrtAppT *app, BasControlT *ctrl, const char *method, const int32_t *vals, int32_t count) { + BasValueT args[8]; + + for (int32_t i = 0; i < count; i++) { + args[i] = basValLong(vals[i]); + } + + return basFormRtCallMethod(app->rt, ctrl, method, args, count); +} + + +static void caseCommonMethods(void) { + tCase("common methods"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"CM\"\n" + " Begin TextBox Txt1\n" + " End\n" + " Begin CommandButton Cmd1\n" + " Caption = \"Go\"\n" + " End\n" + " Begin Toolbar Tb1\n" + " End\n" + "End\n" + "\n" + "Sub Form1_Test\n" + " Txt1.SetFocus\n" + " Cmd1.Refresh\n" + " Cmd1.SetReadOnly True\n" + " Cmd1.SetEnabled False\n" + " Cmd1.SetVisible False\n" + " Me.CreateMenu \"dyn\"\n" + " Me.AddMenuItem \"dyn\", \"dynItem\", \"Dynamic\"\n" + " Me.AddMenuSeparator \"dyn\"\n" + " Me.AddSubMenu \"dyn\", \"dynSub\", \"More\"\n" + " Me.AddMenuItem \"dynSub\", \"dynSubItem\", \"Nested\"\n" + " Tb1.AddButton \"TbImg\", \"" FRT_FIXTURE_DIR "/icon.bmp\"\n" + " Tb1.AddTextButton \"TbTxt\", \"Text\"\n" + " Tb1.AddSeparator\n" + " Print \"count=\"; Tb1.ButtonCount()\n" + " Print \"pm=\"; Cmd1.Caption\n" + "End Sub\n" + "Sub Form1_Destroy\n" + " Me.DestroyMenu \"dyn\"\n" + " Me.DestroyMenu \"dyn\"\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + frtFire(&app, "Form1", "Test"); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *txt = frtCtrl(form, "Txt1"); + BasControlT *cmd = frtCtrl(form, "Cmd1"); + BasControlT *tb = frtCtrl(form, "Tb1"); + + TEST_EQ(app.mainResult, BAS_VM_HALTED); + TEST_TRUE(!app.rt->terminated); + TEST_TRUE(txt && cmd && tb); + + if (txt && cmd && tb) { + TEST_PTR_EQ(wgtGetFocused(), txt->widget); + TEST_TRUE(cmd->widget->readOnly); + TEST_TRUE(!cmd->widget->enabled); + TEST_TRUE(!cmd->widget->visible); + TEST_EQ(form->popupMenuCount, 2); + TEST_EQ(form->menuIdMapCount, 2); + TEST_STR_EQ(form->popupMenus[0].name, "dyn"); + TEST_STR_EQ(form->popupMenus[1].name, "dynSub"); + TEST_TRUE(form->popupMenus[0].ownsMenu); + TEST_TRUE(!form->popupMenus[1].ownsMenu); + TEST_EQ(form->popupMenus[0].menu->itemCount, 3); + TEST_TRUE(form->popupMenus[0].menu->items[1].separator); + TEST_PTR_EQ(form->popupMenus[0].menu->items[2].subMenu, form->popupMenus[1].menu); + TEST_STR_EQ(form->menuIdMap[0].name, "dynItem"); + TEST_STR_EQ(form->menuIdMap[1].name, "dynSubItem"); + TEST_TRUE(frtCtrl(form, "TbImg") != NULL); + TEST_TRUE(frtCtrl(form, "TbTxt") != NULL); + TEST_STR_EQ(wgtGetText(frtCtrl(form, "TbTxt")->widget), "Text"); + outContains(&app, "count=3 "); + outContains(&app, "pm=Go"); + } + + // DestroyMenu on the root drops the alias too; on a stale name it is + // a no-op. + frtFire(&app, "Form1", "Destroy"); + TEST_TRUE(!app.rt->terminated); + TEST_EQ(form->popupMenuCount, 0); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseControlArrays(void) { + tCase("control arrays pass Index"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Arr\"\n" + " Begin CommandButton Btn\n" + " Index = 0\n" + " Caption = \"Zero\"\n" + " End\n" + " Begin CommandButton Btn\n" + " Index = 1\n" + " Caption = \"One\"\n" + " End\n" + " Begin CommandButton Btn\n" + " Index = 5\n" + " Caption = \"Five\"\n" + " End\n" + "End\n" + "\n" + "Sub Btn_Click(Index As Integer)\n" + " Print \"click=\"; Index\n" + "End Sub\n" + "Sub Form1_Test\n" + " Btn(1).Caption = \"Uno\"\n" + " Print \"cap=\"; Btn(5).Caption\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + frtFire(&app, "Form1", "Test"); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *b0 = frtCtrlIdx(form, "Btn", 0); + BasControlT *b1 = frtCtrlIdx(form, "Btn", 1); + BasControlT *b5 = frtCtrlIdx(form, "Btn", 5); + + TEST_EQ(app.mainResult, BAS_VM_HALTED); + TEST_TRUE(b0 && b1 && b5); + TEST_PTR_EQ(frtCtrl(form, "Btn"), NULL); + + if (b0 && b1 && b5) { + int32_t sx = 0; + int32_t sy = 0; + + TEST_STR_EQ(wgtGetText(b1->widget), "Uno"); + outContains(&app, "cap=Five"); + frtOutClear(&app); + tWidgetCenter(b5->widget, &sx, &sy); + tClick(sx, sy); + tWidgetCenter(b0->widget, &sx, &sy); + tClick(sx, sy); + TEST_STR_EQ(frtOut(&app), "click=5 \nclick=0 \n"); + } + + frtDestroy(&app); + tShutdown(); +} + + +static void caseCreateFormDynamic(void) { + tCase("CreateForm/CreateControl at runtime"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = "Begin Form Form1\n Caption = \"Main\"\nEnd\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *f2 = (BasFormT *)basFormRtCreateForm(app.rt, "Form2", 200, 150); + + TEST_TRUE(f2 != NULL); + TEST_PTR_EQ(basFormRtCreateForm(app.rt, "Form2", 300, 300), f2); + + BasFormT *f3 = (BasFormT *)basFormRtCreateForm(app.rt, "Form3", 0, 0); + + TEST_TRUE(f3 != NULL); + TEST_EQ(f3->frmWidth, BAS_DEFAULT_FORM_W); + TEST_PTR_EQ(basFormRtCreateForm(app.rt, NULL, 10, 10), NULL); + TEST_PTR_EQ(basFormRtCreateForm(NULL, "F", 10, 10), NULL); + + BasControlT *btn = (BasControlT *)basFormRtCreateCtrl(app.rt, f2, "CommandButton", "B1"); + + TEST_TRUE(btn != NULL && btn->widget != NULL); + + BasControlT *frame = (BasControlT *)basFormRtCreateCtrl(app.rt, f2, "Frame", "Fr1"); + BasControlT *inner = (BasControlT *)basFormRtCreateCtrlEx(app.rt, f2, "TextBox", "T1", frame); + + TEST_TRUE(frame != NULL && inner != NULL); + TEST_PTR_EQ(childAt(frame->widget, 0), inner->widget); + + basFormRtShowForm(app.rt, f2, false); + tPump(1); + TEST_TRUE(f2->window->visible); + basFormRtHideForm(app.rt, f2); + tPump(1); + TEST_TRUE(!f2->window->visible); + + // Direct type names (already-canonical widget names) resolve too. + TEST_TRUE(basFormRtCreateCtrl(app.rt, f2, "button", "RawB") != NULL); + + // Unloading the modal form clears the modal gate. + basFormRtShowForm(app.rt, f3, true); + TEST_PTR_EQ(tCtx()->modalWindow, f3->window); + basFormRtUnloadForm(app.rt, f3); + tPump(1); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + + // The window factory's auto-size and explicit-position paths. + WidgetT *auxRoot = NULL; + WidgetT *auxBox = NULL; + WindowT *auxWin = basFormRtCreateFormWindow(tCtx(), "AutoT", "HBox", false, false, true, 0, 0, 0, 0, &auxRoot, &auxBox); + + TEST_TRUE(auxWin != NULL); + dvxDestroyWindow(tCtx(), auxWin); + auxWin = basFormRtCreateFormWindow(tCtx(), "PosT", "VBox", false, false, false, 100, 80, 50, 60, &auxRoot, &auxBox); + TEST_TRUE(auxWin != NULL && auxWin->x == 50 && auxWin->y == 60); + dvxDestroyWindow(tCtx(), auxWin); + tPump(1); + + // A windowless form raises on Show/Hide and control creation. + BasFormT ghost; + + memset(&ghost, 0, sizeof(ghost)); + snprintf(ghost.name, sizeof(ghost.name), "Ghost"); + basFormRtShowForm(app.rt, &ghost, false); + frtClearError(&app); + basFormRtHideForm(app.rt, &ghost); + frtClearError(&app); + TEST_PTR_EQ(basFormRtCreateCtrlEx(app.rt, &ghost, "TextBox", "G1", NULL), NULL); + frtClearError(&app); + + // Unknown method on a form reference names the form in the error. + BasFormT *form1 = frtForm(&app, "Form1"); + + basFormRtCallMethod(app.rt, form1, "NoSuchMethod", NULL, 0); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + + // Unknown type / NULL form raise runtime errors and halt; clear each. + TEST_PTR_EQ(basFormRtCreateCtrl(app.rt, f2, "Sprocket", "S1"), NULL); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + TEST_PTR_EQ(basFormRtCreateCtrl(app.rt, NULL, "TextBox", "T2"), NULL); + frtClearError(&app); + basFormRtShowForm(app.rt, NULL, false); + frtClearError(&app); + basFormRtHideForm(app.rt, NULL); + frtClearError(&app); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseDataBinding(void) { + tCase("Data control binding without a database behind it"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"DB\"\n" + " Begin Data Dat1\n" + " End\n" + " Begin TextBox Txt1\n" + " DataSource = \"Dat1\"\n" + " DataField = \"name\"\n" + " End\n" + " Begin DBGrid Grd1\n" + " DataSource = \"Dat1\"\n" + " End\n" + " Begin Data Det1\n" + " MasterSource = \"Dat1\"\n" + " MasterField = \"id\"\n" + " End\n" + "End\n" + "\n" + "Sub Dat1_Reposition\n" + " Print \"repo\"\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *dat = frtCtrl(form, "Dat1"); + BasControlT *txt = frtCtrl(form, "Txt1"); + + TEST_TRUE(dat != NULL && txt != NULL); + TEST_TRUE(!app.rt->terminated); + + // Every navigation method is a no-op without a database but must + // dispatch cleanly, and Refresh re-runs the bound-control update. + static const char *const moves[] = { "Refresh", "MoveFirst", "MoveNext", "MovePrevious", "MoveLast", "AddNew", "Update", "Delete" }; + + for (int32_t i = 0; i < (int32_t)(sizeof(moves) / sizeof(moves[0])); i++) { + callIntM(&app, dat, moves[i], NULL, 0); + TEST_TRUE(!app.rt->terminated); + } + + tPump(1); + + // LostFocus write-back path on the bound TextBox. + callIntM(&app, txt, "SetFocus", NULL, 0); + tPump(1); + + BasControlT *grd = frtCtrl(form, "Grd1"); + + callIntM(&app, grd, "SetFocus", NULL, 0); + tPump(1); + TEST_TRUE(!app.rt->terminated); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseDialogWrappers(void) { + tCase("choice/input/file dialog wrappers incl. no-runtime guards"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + // No form runtime yet: every wrapper takes its guard path. + TEST_EQ(basChoiceDialog("t", "p", "A|B", 0), -1); + TEST_STR_EQ(basInputBox2("t", "p", "d"), ""); + TEST_EQ(basInputCancelled(), -1); + TEST_EQ(basIntInput("t", "p", 7, 0, 10), 7); + TEST_EQ(basPromptSave("t"), DVX_SAVE_NO); + TEST_STR_EQ(basFileOpen("t", "*.txt"), ""); + TEST_STR_EQ(basFileSave("t", ""), ""); + + const char *frm = "Begin Form Form1\n Caption = \"Dlg\"\nEnd\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + DialogCtxT dlg; + + memset(&dlg, 0, sizeof(dlg)); + snprintf(dlg.button, sizeof(dlg.button), "OK"); + tDialogScript(dialogClick, &dlg); + TEST_EQ(basChoiceDialog("Pick", "p", "A|B|C", 1), 1); + TEST_EQ(basChoiceDialog("Pick", "p", "", 0), -1); + TEST_EQ(basChoiceDialog("Pick", "p", NULL, 0), -1); + + TEST_STR_EQ(basInputBox2("T", "P", "dflt"), "dflt"); + TEST_EQ(basInputCancelled(), 0); + TEST_EQ(basIntInput("T", "P", 5, 0, 9), 5); + + snprintf(dlg.button, sizeof(dlg.button), "Cancel"); + TEST_STR_EQ(basInputBox2("T", "P", "dflt"), ""); + TEST_EQ(basInputCancelled(), -1); + TEST_EQ(basPromptSave("T"), DVX_SAVE_CANCEL); + snprintf(dlg.button, sizeof(dlg.button), "Yes"); + TEST_EQ(basPromptSave("T"), DVX_SAVE_YES); + snprintf(dlg.button, sizeof(dlg.button), "No"); + TEST_EQ(basPromptSave("T"), DVX_SAVE_NO); + + // Typing into the VM-facing InputBox callback. + FileDlgCtxT typer; + + memset(&typer, 0, sizeof(typer)); + typer.typeText = "abc"; + tDialogScript(fileDialogType, &typer); + + BasStringT *typed = app.vm->ui.inputBox(app.vm->ui.ctx, "P", "T", ""); + + TEST_TRUE(typed != NULL && strcmp(typed->data, "abc") == 0); + basStringUnref(typed); + + // Esc cancels: the VM callback reports NULL, the wrappers "". + memset(&typer, 0, sizeof(typer)); + tDialogScript(fileDialogType, &typer); + TEST_PTR_EQ(app.vm->ui.inputBox(app.vm->ui.ctx, "P", "T", "d"), NULL); + + // File dialogs: cancel through every filter form parseFileFilters + // accepts (none, old-style single pattern, piped labels). + memset(&typer, 0, sizeof(typer)); + tDialogScript(fileDialogType, &typer); + TEST_STR_EQ(basFileOpen("Open", NULL), ""); + TEST_STR_EQ(basFileOpen("Open", "*.txt"), ""); + TEST_STR_EQ(basFileSave("Save", "Text (*.txt)|All (*.*)"), ""); + TEST_TRUE(typer.seen >= 3); + + // Accepting the save dialog with a typed name returns the path. + memset(&typer, 0, sizeof(typer)); + typer.typeText = "ts_save_pick.txt"; + tDialogScript(fileDialogType, &typer); + + const char *savePick = basFileSave("Save", NULL); + + TEST_TRUE(strstr(savePick, "ts_save_pick.txt") != NULL); + + // MsgBox default title. + memset(&dlg, 0, sizeof(dlg)); + snprintf(dlg.button, sizeof(dlg.button), "OK"); + tDialogScript(dialogClick, &dlg); + basFormRtMsgBox(app.rt, "hello", 0, NULL); + TEST_STR_EQ(dlg.title, "DVX BASIC"); + + tDialogScript(NULL, NULL); + frtDestroy(&app); + tShutdown(); +} + + +static void caseEventLoopExits(void) { + tCase("basFormRtEventLoop exit conditions"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Loop\"\n" + " Begin CommandButton Quit\n" + " Caption = \"Quit\"\n" + " End\n" + "End\n" + "\n" + "Sub Quit_Click\n" + " Unload Me\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + // Guard paths first: NULL runtime and a stopped context return at once. + basFormRtEventLoop(NULL); + tCtx()->running = false; + basFormRtEventLoop(app.rt); + tCtx()->running = true; + + // Queue a click on the Quit button, then enter the loop: the click is + // consumed inside the loop, the handler unloads the last form, and + // the loop exits on the empty form list. + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *quit = frtCtrl(form, "Quit"); + int32_t sx = 0; + int32_t sy = 0; + + tWidgetCenter(quit->widget, &sx, &sy); + hostMousePush(sx, sy, 0); + hostMousePush(sx, sy, MOUSE_LEFT); + hostMousePush(sx, sy, 0); + basFormRtEventLoop(app.rt); + TEST_EQ((int32_t)arrlen(app.rt->forms), 0); + frtDestroy(&app); + + // A runtime error inside a handler terminates the loop. + const char *frmErr = + "Begin Form Form1\n" + " Caption = \"LoopErr\"\n" + " Begin CommandButton Boom\n" + " Caption = \"Boom\"\n" + " End\n" + "End\n" + "\n" + "Sub Boom_Click\n" + " NoSuchCtrl.Text = \"x\"\n" + "End Sub\n"; + + if (!expectBuild(&app, "", &frmErr, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + form = frtForm(&app, "Form1"); + + BasControlT *boom = frtCtrl(form, "Boom"); + + tWidgetCenter(boom->widget, &sx, &sy); + hostMousePush(sx, sy, 0); + hostMousePush(sx, sy, MOUSE_LEFT); + hostMousePush(sx, sy, 0); + basFormRtEventLoop(app.rt); + TEST_TRUE(app.rt->terminated); + TEST_TRUE(app.dialogsDismissed >= 1); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseEvents(void) { + tCase("every BAS_EVENT_LIST event reaches its handler"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Ev\"\n" + " Width = 400\n" + " Height = 300\n" + " Begin TextBox Txt1\n" + " End\n" + " Begin CommandButton Cmd1\n" + " Caption = \"Go\"\n" + " End\n" + " Begin Label Lbl1\n" + " Caption = \"Label\"\n" + " End\n" + " Begin ListBox Lst1\n" + " End\n" + " Begin Timer Tmr1\n" + " Interval = 100\n" + " Enabled = False\n" + " End\n" + " Begin Data Dat1\n" + " End\n" + "End\n" + "\n" + "Sub Form1_Load\n" + " Print \"Load\"\n" + "End Sub\n" + "Sub Form1_Unload\n" + " Print \"Unload\"\n" + "End Sub\n" + "Sub Form1_QueryUnload(Cancel As Integer)\n" + " Print \"QueryUnload=\"; Cancel\n" + "End Sub\n" + "Sub Form1_Resize\n" + " Print \"Resize\"\n" + "End Sub\n" + "Sub Form1_Activate\n" + " Print \"Activate\"\n" + "End Sub\n" + "Sub Form1_Deactivate\n" + " Print \"Deactivate\"\n" + "End Sub\n" + "Sub Cmd1_Click\n" + " Print \"Click\"\n" + "End Sub\n" + "Sub Lst1_DblClick\n" + " Print \"DblClick\"\n" + "End Sub\n" + "Sub Txt1_Change\n" + " Print \"Change=\"; Txt1.Text\n" + "End Sub\n" + "Sub Tmr1_Timer\n" + " Print \"Timer\"\n" + "End Sub\n" + "Sub Txt1_GotFocus\n" + " Print \"GotFocus\"\n" + "End Sub\n" + "Sub Txt1_LostFocus\n" + " Print \"LostFocus\"\n" + "End Sub\n" + "Sub Txt1_KeyPress(KeyAscii As Integer)\n" + " Print \"KeyPress=\"; KeyAscii\n" + "End Sub\n" + "Sub Txt1_KeyDown(KeyCode As Integer, Shift As Integer)\n" + " Print \"KeyDown=\"; KeyCode; Shift\n" + "End Sub\n" + "Sub Txt1_KeyUp(KeyCode As Integer, Shift As Integer)\n" + " Print \"KeyUp=\"; KeyCode; Shift\n" + "End Sub\n" + "Sub Lbl1_MouseDown(Button As Integer, X As Integer, Y As Integer)\n" + " Print \"MouseDown=\"; Button; X; Y\n" + "End Sub\n" + "Sub Lbl1_MouseUp(Button As Integer, X As Integer, Y As Integer)\n" + " Print \"MouseUp=\"; Button; X; Y\n" + "End Sub\n" + "Sub Lbl1_MouseMove(Button As Integer, X As Integer, Y As Integer)\n" + " Print \"MouseMove=\"; Button; X; Y\n" + "End Sub\n" + "Sub Lst1_Scroll(Delta As Integer)\n" + " Print \"Scroll=\"; Delta\n" + "End Sub\n" + "Sub Dat1_Reposition\n" + " Print \"Reposition\"\n" + "End Sub\n" + "Sub Txt1_Validate(Cancel As Integer)\n" + " Print \"Validate=\"; Cancel\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *txt = frtCtrl(form, "Txt1"); + BasControlT *cmd = frtCtrl(form, "Cmd1"); + BasControlT *lbl = frtCtrl(form, "Lbl1"); + BasControlT *lst = frtCtrl(form, "Lst1"); + BasControlT *tmr = frtCtrl(form, "Tmr1"); + BasControlT *dat = frtCtrl(form, "Dat1"); + int32_t sx = 0; + int32_t sy = 0; + + TEST_TRUE(txt && cmd && lbl && lst && tmr && dat); + + if (!(txt && cmd && lbl && lst && tmr && dat)) { + frtDestroy(&app); + tShutdown(); + return; + } + + // Lifecycle: Resize fires from the .frm geometry apply, Load after + // the controls exist, Activate on Show. + outContains(&app, "Resize\n"); + outContains(&app, "Load\n"); + outContains(&app, "Activate\n"); + TEST_TRUE(strstr(frtOut(&app), "Resize") < strstr(frtOut(&app), "Load")); + TEST_TRUE(strstr(frtOut(&app), "Load") < strstr(frtOut(&app), "Activate")); + + // Click / DblClick through the full input path. + frtOutClear(&app); + tWidgetCenter(cmd->widget, &sx, &sy); + tClick(sx, sy); + outContains(&app, "Click\n"); + // DblClick comes from list-type widgets on a selected row; drive the + // wired callback directly. + frtOutClear(&app); + lst->widget->onDblClick(lst->widget); + TEST_STR_EQ(frtOut(&app), "DblClick\n"); + + // Focus + keys on the text box (the button holds focus first). + tWidgetClick(cmd->widget); + frtOutClear(&app); + tWidgetCenter(txt->widget, &sx, &sy); + tClick(sx, sy); + TEST_PTR_EQ(wgtGetFocused(), txt->widget); + outContains(&app, "GotFocus\n"); + frtOutClear(&app); + tType("a"); + outContains(&app, "KeyPress=97 \n"); + outContains(&app, "KeyDown=97 0 \n"); + outContains(&app, "KeyUp="); + outContains(&app, "Change=a\n"); + + // LostFocus when the button takes focus. + frtOutClear(&app); + tWidgetCenter(cmd->widget, &sx, &sy); + tClick(sx, sy); + outContains(&app, "LostFocus\n"); + + // Mouse events on a plain widget carry button and widget-relative + // coordinates. + frtOutClear(&app); + tWidgetCenter(lbl->widget, &sx, &sy); + tMouseMove(sx, sy); + tMouseDown(sx, sy, MOUSE_LEFT); + tMouseUp(sx, sy, MOUSE_LEFT); + tPump(1); + + char expect[64]; + + snprintf(expect, sizeof(expect), "MouseDown=1 %d %d \n", (int)(lbl->widget->w / 2), (int)(lbl->widget->h / 2)); + outContains(&app, expect); + snprintf(expect, sizeof(expect), "MouseUp=1 %d %d \n", (int)(lbl->widget->w / 2), (int)(lbl->widget->h / 2)); + outContains(&app, expect); + outContains(&app, "MouseMove=0 "); + + // Scroll carries the wheel delta (the list has nothing to scroll, so + // drive the wired callback directly). + frtOutClear(&app); + lst->widget->onScroll(lst->widget, 3); + TEST_STR_EQ(frtOut(&app), "Scroll=3 \n"); + + // Timer through the fake clock. + frtOutClear(&app); + wgtTimerStart(tmr->widget); + tAdvanceClock(TIMER_INTERVAL_MS); + TEST_STR_EQ(frtOut(&app), "Timer\n"); + wgtTimerStop(tmr->widget); + + // Reposition and Validate come from the Data control's change and + // validate hooks; drive the wired callbacks directly. + frtOutClear(&app); + dat->widget->onChange(dat->widget); + TEST_STR_EQ(frtOut(&app), "Reposition\n"); + frtOutClear(&app); + TEST_TRUE(txt->widget->onValidate(txt->widget)); + TEST_STR_EQ(frtOut(&app), "Validate=0 \n"); + + // Deactivate / Activate on a focus change between two windows. + frtOutClear(&app); + + WindowT *other = dvxCreateWindow(tCtx(), "Other", 10, 10, 100, 100, false); + + dvxRaiseWindow(tCtx(), other); + tPump(1); + outContains(&app, "Deactivate\n"); + frtOutClear(&app); + dvxRaiseWindow(tCtx(), form->window); + tPump(1); + outContains(&app, "Activate\n"); + + // Resize from the window manager. + frtOutClear(&app); + dvxResizeWindow(tCtx(), form->window, FORM_W + 20, FORM_H + 20); + tPump(1); + outContains(&app, "Resize\n"); + + // QueryUnload (Cancel = 0) then Unload on the close box. + frtOutClear(&app); + tAltF4(); + tPump(1); + TEST_STR_EQ(frtOut(&app), "QueryUnload=0 \nUnload\n"); + TEST_PTR_EQ(frtForm(&app, "Form1"), NULL); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseExternCalls(void) { + tCase("DECLARE LIBRARY through basExternResolve/basExternCall"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + // The host trampoline (basNativeHost) calls nothing and returns 0, + // so every value comes back as the zero of its declared type; the + // marshaling paths (string dup, double/single two-slot, narrows) + // still all run. + const char *bas = + "DECLARE LIBRARY \"test\"\n" + " DECLARE FUNCTION frtExtInt(BYVAL a AS INTEGER) AS INTEGER\n" + " DECLARE FUNCTION frtExtLong(BYVAL a AS LONG, BYVAL b AS LONG) AS LONG\n" + " DECLARE FUNCTION frtExtDbl(BYVAL d AS DOUBLE, BYVAL s AS SINGLE) AS DOUBLE\n" + " DECLARE FUNCTION frtExtStr(BYVAL s AS STRING) AS STRING\n" + " DECLARE FUNCTION frtExtFlag(BYVAL a AS INTEGER) AS BOOLEAN\n" + " DECLARE SUB frtExtSub(BYVAL a AS INTEGER)\n" + "END DECLARE\n" + "PRINT frtExtInt(5)\n" + "PRINT frtExtLong(1, 2)\n" + "PRINT frtExtDbl(1.5, 2.5)\n" + "DIM sng AS SINGLE\n" + "sng = 2.5\n" + "PRINT frtExtDbl(1.5, sng)\n" + "PRINT \"[\" & frtExtStr(\"abc\") & \"]\"\n" + "PRINT frtExtFlag(1)\n" + "frtExtSub 3\n" + "PRINT \"done\"\n"; + const char *frm = "Begin Form Form1\n Caption = \"Ext\"\nEnd\n"; + FrtAppT app; + + if (!expectBuild(&app, bas, &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + TEST_STR_EQ(frtOut(&app), "0 \n0 \n0 \n0 \n[]\nFalse \ndone\n"); + TEST_TRUE(basExternResolve(NULL, "test", "frtExtInt") != NULL); + TEST_PTR_EQ(basExternResolve(NULL, "test", "frtExtNoSuchSymbol"), NULL); + frtDestroy(&app); + + // An unresolvable name raises catchable error 453. + const char *basMissing = + "DECLARE LIBRARY \"test\"\n" + " DECLARE SUB frtExtNope()\n" + "END DECLARE\n" + "ON ERROR GOTO H\n" + "frtExtNope\n" + "PRINT \"no\"\n" + "END\n" + "H:\n" + "PRINT \"e\"; ERR\n" + "END\n"; + + if (!expectBuild(&app, basMissing, &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + outContains(&app, "453"); + frtDestroy(&app); + tShutdown(); +} + + +static void caseFormShowHide(void) { + tCase("Show modal / non-modal and Hide"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm1 = + "Begin Form Form1\n" + " Caption = \"One\"\n" + "End\n"; + const char *frm2 = + "Begin Form Form2\n" + " Caption = \"Two\"\n" + "End\n"; + const char *frms[2] = { frm1, frm2 }; + const char *bas = + "Form2.Show True\n" + "Print \"modal=\"; Form2.Visible\n" + "Form2.Hide\n" + "Print \"hidden=\"; Form2.Visible\n" + "Form2.Show\n" + "Form2.Visible = False\n" + "Form2.Visible = True\n"; + FrtAppT app; + + if (!expectBuild(&app, bas, frms, 2)) { + tShutdown(); + return; + } + + basFormRtLoadAllForms(app.rt, "Form1"); + tPump(1); + + BasFormT *f1 = frtForm(&app, "Form1"); + BasFormT *f2 = frtForm(&app, "Form2"); + + TEST_TRUE(f1 && f2); + TEST_TRUE(f1->window->visible); + TEST_TRUE(!f2->window->visible); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + + // Step the program one statement at a time by checking state after + // each phase: the whole main runs to completion here, so assert the + // printed snapshots plus the final state. + frtRunMain(&app); + tPump(1); + TEST_EQ(app.mainResult, BAS_VM_HALTED); + outContains(&app, "modal=True \n"); + outContains(&app, "hidden=False \n"); + TEST_TRUE(f2->window->visible); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + + // Modal gate directly: Show True sets it, Hide clears it. + basFormRtShowForm(app.rt, f2, true); + TEST_PTR_EQ(tCtx()->modalWindow, f2->window); + basFormRtHideForm(app.rt, f2); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + TEST_TRUE(!f2->window->visible); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseFormVars(void) { + tCase("form-scope variables are per form and reset on reload"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm1 = + "Begin Form Form1\n" + " Caption = \"One\"\n" + "End\n" + "\n" + "Dim counter As Integer\n" + "Dim label As String\n" + "label = \"one\"\n" + "Sub Form1_Load\n" + " counter = counter + 1\n" + " Print \"F1:\"; counter; label\n" + "End Sub\n" + "Sub Form1_Bump\n" + " counter = counter + 10\n" + " Print \"F1:\"; counter\n" + "End Sub\n"; + const char *frm2 = + "Begin Form Form2\n" + " Caption = \"Two\"\n" + "End\n" + "\n" + "Dim counter As Integer\n" + "Dim label As String\n" + "label = \"two\"\n" + "Sub Form2_Load\n" + " counter = counter + 2\n" + " Print \"F2:\"; counter; label\n" + "End Sub\n"; + const char *frms[2] = { frm1, frm2 }; + const char *bas = + "Unload Form2\n" + "Load Form2\n"; + FrtAppT app; + + if (!expectBuild(&app, bas, frms, 2)) { + tShutdown(); + return; + } + + frtStart(&app, "Form1"); + TEST_EQ(app.mainResult, BAS_VM_HALTED); + TEST_STR_EQ(frtOut(&app), "F1:1 one\nF2:2 two\nF2:2 two\n"); + + BasFormT *f1 = frtForm(&app, "Form1"); + + TEST_TRUE(f1 && f1->formVarCount == 2); + frtOutClear(&app); + basFormRtFireEvent(app.rt, f1, "Form1", "Bump"); + TEST_STR_EQ(frtOut(&app), "F1:11 \n"); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseFrmLoadEdges(void) { + tCase(".frm load edges: version, unknown type, geometry, nesting"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + // A .frm claiming a too-new VB version is refused whole. + const char *frmVer = "VERSION 9.99\nBegin Form FormV\nEnd\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frmVer, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + TEST_PTR_EQ(frtForm(&app, "FormV"), NULL); + TEST_TRUE(!app.rt->terminated); + TEST_PTR_EQ(basFormRtLoadFrm(app.rt, NULL, 0), NULL); + frtDestroy(&app); + + // Unknown widget type: loud error, form discarded. + const char *frmBad = "Begin Form FormU\n Begin Sprocket S1\n End\nEnd\n"; + + if (!expectBuild(&app, "", &frmBad, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + TEST_TRUE(app.rt->terminated); + TEST_PTR_EQ(frtForm(&app, "FormU"), NULL); + frtClearError(&app); + frtDestroy(&app); + + // Geometry, layout, control arrays, popup context menus. + const char *frmGeo = + "Begin Form Form1\n" + " Caption = \"Geo\"\n" + " Width = 320\n" + " Height = 200\n" + " Left = 30\n" + " Top = 40\n" + " Centered = False\n" + " Resizable = False\n" + " HelpTopic = \"geoHelp\"\n" + " ContextMenu = \"mnuPop\"\n" + " Begin Menu mnuPop\n" + " Caption = \"Pop\"\n" + " Visible = False\n" + " Begin Menu mnuPopA\n" + " Caption = \"A\"\n" + " End\n" + " End\n" + " Begin Frame Fr\n" + " Layout = \"HBox\"\n" + " Begin TextBox T\n" + " Index = 2\n" + " ContextMenu = \"mnuPop\"\n" + " End\n" + " End\n" + "End\n"; + + if (!expectBuild(&app, "", &frmGeo, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + + TEST_TRUE(form != NULL); + TEST_EQ(form->window->x, 30); + TEST_EQ(form->window->y, 40); + TEST_TRUE(!form->window->resizable); + TEST_STR_EQ(form->helpTopic, "geoHelp"); + TEST_TRUE(form->window->contextMenu != NULL); + TEST_TRUE(frtCtrlIdx(form, "T", 2) != NULL); + + BasControlT *t = frtCtrlIdx(form, "T", 2); + + TEST_TRUE(t->widget->contextMenu != NULL); + TEST_TRUE(!app.rt->terminated); + + // Cross-form indexed lookup with no current form context. + TEST_TRUE(basFormRtFindCtrlIdx(app.rt, NULL, "T", 2) != NULL); + + // A SetEvent override naming a missing SUB consumes the event. + basFormRtSetEvent(app.rt, t, "Click", "NoSuchHandler"); + tWidgetClick(t->widget); + tPump(1); + TEST_TRUE(!app.rt->terminated); + + // Direct .frm load caches the source; unload then reload by name. + const char *frmExtra = "Begin Form FormX\n Caption = \"X\"\nEnd\n"; + BasFormT *fx = basFormRtLoadFrm(app.rt, frmExtra, (int32_t)strlen(frmExtra)); + + TEST_TRUE(fx != NULL); + basFormRtUnloadForm(app.rt, fx); + tPump(1); + TEST_TRUE(basFormRtLoadForm(app.rt, "FormX") != NULL); + frtDestroy(&app); + + // AutoSize forms fit at load and again on Show. + const char *frmAuto = + "Begin Form FormA\n" + " AutoSize = True\n" + " Begin Label L\n" + " Caption = \"fit\"\n" + " End\n" + "End\n"; + + if (!expectBuild(&app, "", &frmAuto, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *fa = frtForm(&app, "FormA"); + + TEST_TRUE(fa != NULL && fa->frmAutoSize); + basFormRtShowForm(app.rt, fa, false); + tPump(1); + frtDestroy(&app); + + // A bad ENUM value in the .frm raises the bad-property error. + const char *frmBadEnum = + "Begin Form FormE\n" + " Begin Label L\n" + " Alignment = \"Wat\"\n" + " End\n" + "End\n"; + + if (!expectBuild(&app, "", &frmBadEnum, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + frtDestroy(&app); + + // Nesting deeper than the parser allows: rejected, form discarded. + char deep[FRM_TEXT_MAX]; + int32_t pos = snprintf(deep, sizeof(deep), "Begin Form FormD\n"); + + for (int32_t i = 0; i < FRM_MAX_NESTING + 2; i++) { + pos += snprintf(deep + pos, sizeof(deep) - pos, "Begin Frame F%d\n", (int32_t)i); + } + + for (int32_t i = 0; i < FRM_MAX_NESTING + 3; i++) { + pos += snprintf(deep + pos, sizeof(deep) - pos, "End\n"); + } + + const char *frmDeep = deep; + + if (!expectBuild(&app, "", &frmDeep, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + TEST_PTR_EQ(frtForm(&app, "FormD"), NULL); + frtClearError(&app); + frtDestroy(&app); + tShutdown(); +} + + +static void caseFrmParserUnit(void) { + tCase("frmParser callbacks, key/value and quote helpers"); + + const char *src = + "VERSION DVX 1.00\n" + "Begin Form F\n" + " Cap = \"x\"\n" + " Begin Menu M1\n" + " Caption = \"&File\"\n" + " Begin Menu M2\n" + " Caption = \"Sub\"\n" + " End\n" + " End\n" + " Begin TextBox T\n" + " Text = \"hi\"\n" + " End\n" + "End\n" + "Sub Form_Load\nEnd Sub\n"; + ParseRecT rec; + FrmParserCbsT cbs; + + memset(&rec, 0, sizeof(rec)); + memset(&cbs, 0, sizeof(cbs)); + cbs.userData = &rec; + cbs.onFormBegin = parseOnFormBegin; + cbs.onFormProp = parseOnFormProp; + cbs.onMenuBegin = parseOnMenuBegin; + cbs.onMenuEnd = parseOnMenuEnd; + cbs.onMenuProp = parseOnMenuProp; + cbs.onCtrlBegin = parseOnCtrlBegin; + cbs.onCtrlEnd = parseOnCtrlEnd; + cbs.onCtrlProp = parseOnCtrlProp; + cbs.onFormEnd = parseOnFormEnd; + + TEST_TRUE(frmParse(src, (int32_t)strlen(src), &cbs)); + TEST_EQ(rec.forms, 1); + TEST_EQ(rec.formProps, 1); + TEST_EQ(rec.menus, 2); + TEST_EQ(rec.menuProps, 2); + TEST_EQ(rec.menuEnds, 2); + TEST_EQ(rec.ctrls, 1); + TEST_EQ(rec.ctrlProps, 1); + TEST_EQ(rec.ctrlEnds, 1); + TEST_EQ(rec.lastMenuLevel, 1); + TEST_STR_EQ(rec.lastType, "TextBox"); + TEST_STR_EQ(rec.lastKey, "Text"); + TEST_STR_EQ(rec.lastVal, "\"hi\""); + TEST_TRUE(strstr(rec.tail, "Sub Form_Load") != NULL); + + // onFormBegin refusing the form aborts the parse. + memset(&rec, 0, sizeof(rec)); + rec.failFormBegin = true; + TEST_TRUE(!frmParse(src, (int32_t)strlen(src), &cbs)); + + // A too-new VB version line refuses the input; callbacks may all be + // NULL for a probe parse. + const char *vbNew = "VERSION 9.99\nBegin Form F\nEnd\n"; + + memset(&rec, 0, sizeof(rec)); + TEST_TRUE(!frmParse(vbNew, (int32_t)strlen(vbNew), &cbs)); + + FrmParserCbsT quiet; + + memset(&quiet, 0, sizeof(quiet)); + TEST_TRUE(frmParse(src, (int32_t)strlen(src), &quiet)); + + // Key/value and quote helpers. + char key[FRM_MAX_TOKEN_LEN]; + char val[FRM_MAX_LINE_LEN]; + + frmParseKeyValue("Alpha = beta gamma ", key, sizeof(key), val, sizeof(val)); + TEST_STR_EQ(key, "Alpha"); + TEST_STR_EQ(val, "beta gamma"); + frmParseKeyValue("NoEquals", key, sizeof(key), val, sizeof(val)); + TEST_STR_EQ(val, ""); + + char q[32]; + + snprintf(q, sizeof(q), "\"quoted\""); + frmStripQuotes(q); + TEST_STR_EQ(q, "quoted"); + snprintf(q, sizeof(q), "bare"); + frmStripQuotes(q); + TEST_STR_EQ(q, "bare"); + snprintf(q, sizeof(q), "\"open"); + frmStripQuotes(q); + TEST_STR_EQ(q, "open"); + + TEST_TRUE(frmParseBool("True")); + TEST_TRUE(frmParseBool("-1")); + TEST_TRUE(!frmParseBool("0")); + TEST_TRUE(!frmParseBool("banana")); + TEST_TRUE(frmParseBoolDefault("banana", true)); + TEST_TRUE(!frmParseBoolDefault("False", true)); +} + + +static void caseHelpHooks(void) { + tCase("HelpCompile and HelpView through the stub library"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const FrtStubApiT *api = stubsOpen(); + + if (!api) { + TEST_TRUE(api != NULL); + tShutdown(); + return; + } + + basFormRtTestReset(); + + const char *frm = "Begin Form Form1\n Caption = \"Help\"\nEnd\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + stubsClose(); + tShutdown(); + return; + } + + frtStart(&app, NULL); + + TEST_EQ(HelpCompile("guide.dhs", "out.hlp"), -1); + TEST_STR_EQ(api->hlpcLastOutput(), "out.hlp"); + api->hlpcRc(2); + TEST_EQ(HelpCompile("guide.dhs", "fail.hlp"), 0); + TEST_EQ(HelpCompile(NULL, "x.hlp"), 0); + TEST_EQ(HelpCompile("x.dhs", NULL), 0); + + // A bare help file name resolves against the app directory. + char expected[DVX_MAX_PATH * 2]; + + HelpView("topic.hlp"); + snprintf(expected, sizeof(expected), "%s" DVX_PATH_SEP "topic.hlp", app.vm->appPath); + TEST_STR_EQ(api->shellLastApp(), DVX_HELP_VIEWER_APP_LITERAL); + TEST_STR_EQ(api->shellLastArgs(), expected); + + // A path with a directory component passes through unchanged. + HelpView("/tmp/abs.hlp"); + TEST_STR_EQ(api->shellLastArgs(), "/tmp/abs.hlp"); + HelpView(NULL); + + frtDestroy(&app); + basFormRtTestReset(); + stubsClose(); + tShutdown(); +} + + +static void caseLoadForm2(void) { + tCase("Load Form2 from Form1_Load populates Form2"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm1 = + "Begin Form Form1\n" + " Caption = \"One\"\n" + " Begin Label Lbl1\n" + " Caption = \"L1\"\n" + " End\n" + "End\n" + "\n" + "Sub Form1_Load\n" + " Load Form2\n" + " Print \"F1 loaded\"\n" + " Form2.Show\n" + "End Sub\n"; + const char *frm2 = + "Begin Form Form2\n" + " Caption = \"Two\"\n" + " Width = 320\n" + " Height = 200\n" + " Begin Label Lbl2\n" + " Caption = \"L2\"\n" + " End\n" + " Begin Frame Fr2\n" + " Begin CommandButton Cmd2\n" + " Caption = \"C2\"\n" + " End\n" + " End\n" + "End\n" + "\n" + "Sub Form2_Load\n" + " Print \"F2 load \"; Cmd2.Caption\n" + "End Sub\n"; + const char *frms[2] = { frm1, frm2 }; + FrtAppT app; + + if (!expectBuild(&app, "", frms, 2)) { + tShutdown(); + return; + } + + // Register Form1 only through LoadAllForms's first entry: Form2 is + // cached but loaded lazily by Form1_Load. + basFormRtLoadForm(app.rt, "Form1"); + tPump(1); + + BasFormT *f1 = frtForm(&app, "Form1"); + BasFormT *f2 = frtForm(&app, "Form2"); + + TEST_TRUE(f1 && f2); + + if (f2) { + TEST_EQ((int32_t)arrlen(f2->controls), 3); + TEST_TRUE(frtCtrl(f2, "Lbl2") != NULL); + TEST_TRUE(frtCtrl(f2, "Cmd2") != NULL); + TEST_STR_EQ(f2->window->title, "Two"); + TEST_EQ(f2->window->w, 320); + TEST_TRUE(f2->window->visible); + TEST_STR_EQ(frtOut(&app), "F2 load C2\nF1 loaded\n"); + } + + TEST_EQ((int32_t)arrlen(app.rt->forms), 2); + frtDestroy(&app); + tShutdown(); +} + + +static void caseMenus(void) { + tCase(".frm menus: bar, check, radio, disabled, submenu, popup"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Menus\"\n" + " Begin Menu mnuFile\n" + " Caption = \"&File\"\n" + " Begin Menu mnuOpen\n" + " Caption = \"&Open\"\n" + " End\n" + " Begin Menu mnuSep1\n" + " Caption = \"-\"\n" + " End\n" + " Begin Menu mnuChk\n" + " Caption = \"Check\"\n" + " Checked = True\n" + " End\n" + " Begin Menu mnuRad\n" + " Caption = \"Radio\"\n" + " RadioCheck = True\n" + " Checked = True\n" + " End\n" + " Begin Menu mnuDis\n" + " Caption = \"Disabled\"\n" + " Enabled = False\n" + " End\n" + " Begin Menu mnuSub\n" + " Caption = \"Sub\"\n" + " Begin Menu mnuSubItem\n" + " Caption = \"Nested\"\n" + " End\n" + " End\n" + " End\n" + " Begin Menu mnuPop\n" + " Caption = \"Popup\"\n" + " Visible = False\n" + " Begin Menu mnuPopItem\n" + " Caption = \"Pop Item\"\n" + " End\n" + " End\n" + " Begin Label Lbl1\n" + " Caption = \"x\"\n" + " End\n" + "End\n" + "\n" + "Sub mnuOpen_Click\n" + " Print \"open\"\n" + "End Sub\n" + "Sub mnuSubItem_Click\n" + " Print \"nested\"\n" + "End Sub\n" + "Sub mnuPopItem_Click\n" + " Print \"pop\"\n" + "End Sub\n" + "Sub Form1_Test\n" + " Print \"chk=\"; mnuChk.Checked; mnuRad.Checked; mnuDis.Enabled; mnuOpen.Enabled\n" + " mnuChk.Checked = False\n" + " mnuDis.Enabled = True\n" + " Print \"after=\"; mnuChk.Checked; mnuDis.Enabled\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + frtFire(&app, "Form1", "Test"); + + BasFormT *form = frtForm(&app, "Form1"); + MenuBarT *bar = form ? form->window->menuBar : NULL; + + TEST_EQ(app.mainResult, BAS_VM_HALTED); + TEST_TRUE(bar != NULL); + + if (bar) { + TEST_EQ(bar->menuCount, 1); + + MenuT *file = bar->menus[0]; + + TEST_STR_EQ(file->label, "&File"); + TEST_EQ(file->itemCount, 6); + TEST_STR_EQ(file->items[0].label, "&Open"); + TEST_TRUE(file->items[1].separator); + TEST_EQ(file->items[2].type, MenuItemCheckE); + TEST_EQ(file->items[3].type, MenuItemRadioE); + TEST_TRUE(file->items[3].checked); + TEST_STR_EQ(file->items[5].label, "Sub"); + TEST_TRUE(file->items[5].subMenu != NULL); + + if (file->items[5].subMenu) { + TEST_STR_EQ(file->items[5].subMenu->items[0].label, "Nested"); + } + + outContains(&app, "chk=True True False True \n"); + outContains(&app, "after=False True \n"); + TEST_TRUE(!file->items[2].checked); + TEST_TRUE(file->items[4].enabled); + + // Popup registered by name; ids dispatch Click handlers whether + // they come from the bar or the popup. + TEST_EQ(form->popupMenuCount, 1); + TEST_STR_EQ(form->popupMenus[0].name, "mnuPop"); + frtOutClear(&app); + form->window->onMenu(form->window, file->items[0].id); + form->window->onMenu(form->window, file->items[5].subMenu->items[0].id); + form->window->onMenu(form->window, form->popupMenus[0].menu->items[0].id); + TEST_STR_EQ(frtOut(&app), "open\nnested\npop\n"); + } + + frtDestroy(&app); + tShutdown(); +} + + +static void caseMethodSignatures(void) { + tCase("every WGT_SIG_* marshaling branch in basFormRtCallMethod"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Sig\"\n" + " Begin ListBox Lst\n" + " End\n" + " Begin PictureBox Pic\n" + " End\n" + " Begin ListView Lv\n" + " End\n" + " Begin DBGrid Grd\n" + " End\n" + " Begin TextArea Ta\n" + " End\n" + " Begin TreeView Tv\n" + " End\n" + " Begin TabStrip Tab\n" + " End\n" + " Begin SpinButton Spn\n" + " End\n" + " Begin Terminal Trm\n" + " End\n" + " Begin Timer Tmr\n" + " Interval = 50\n" + " Enabled = False\n" + " End\n" + "End\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *lst = frtCtrl(form, "Lst"); + BasControlT *pic = frtCtrl(form, "Pic"); + BasControlT *lv = frtCtrl(form, "Lv"); + BasControlT *grd = frtCtrl(form, "Grd"); + BasControlT *ta = frtCtrl(form, "Ta"); + BasControlT *tv = frtCtrl(form, "Tv"); + BasControlT *tab = frtCtrl(form, "Tab"); + BasControlT *spn = frtCtrl(form, "Spn"); + BasControlT *trm = frtCtrl(form, "Trm"); + BasControlT *tmr = frtCtrl(form, "Tmr"); + BasValueT a[5]; + BasValueT r; + + // STR / VOID / RET_INT / RET_STR_INT / INT / INT_BOOL / + // RET_BOOL_INT / BOOL on the list box. + a[0] = basValStringFromC("A"); + callIntM(&app, lst, "SetMultiSelect", (int32_t[]){ -1 }, 1); + basFormRtCallMethod(app.rt, lst, "AddItem", a, 1); + basValRelease(&a[0]); + a[0] = basValStringFromC("B"); + basFormRtCallMethod(app.rt, lst, "AddItem", a, 1); + basValRelease(&a[0]); + r = callIntM(&app, lst, "ListCount", NULL, 0); + TEST_EQ(basValToInt32(r), 2); + r = callIntM(&app, lst, "List", (int32_t[]){ 1 }, 1); + TEST_TRUE(r.type == BAS_TYPE_STRING && strcmp(r.strVal->data, "B") == 0); + basValRelease(&r); + a[0] = basValLong(1); + a[1] = basValBool(true); + basFormRtCallMethod(app.rt, lst, "SetItemSelected", a, 2); + r = callIntM(&app, lst, "IsItemSelected", (int32_t[]){ 1 }, 1); + TEST_TRUE(basValIsTruthy(r)); + callIntM(&app, lst, "SelectAll", NULL, 0); + callIntM(&app, lst, "ClearSelection", NULL, 0); + callIntM(&app, lst, "RemoveItem", (int32_t[]){ 0 }, 1); + r = callIntM(&app, lst, "ListCount", NULL, 0); + TEST_EQ(basValToInt32(r), 1); + callIntM(&app, lst, "Clear", NULL, 0); + + // INT3 / RET_INT_INT_INT / INT5 / INT4 / INT_INT_STR / INT_INT / + // STR on the pixel widget. + callIntM(&app, pic, "Resize", (int32_t[]){ 64, 48 }, 2); + callIntM(&app, pic, "SetPenColor", (int32_t[]){ 4 }, 1); + callIntM(&app, pic, "SetPixel", (int32_t[]){ 2, 3, 5 }, 3); + r = callIntM(&app, pic, "GetPixel", (int32_t[]){ 2, 3 }, 2); + TEST_EQ(basValToInt32(r), 5); + callIntM(&app, pic, "DrawLine", (int32_t[]){ 0, 0, 5, 5, 3 }, 5); + callIntM(&app, pic, "FillCircle", (int32_t[]){ 8, 8, 3, 6 }, 4); + a[0] = basValLong(1); + a[1] = basValLong(1); + a[2] = basValStringFromC("t"); + basFormRtCallMethod(app.rt, pic, "DrawText", a, 3); + basValRelease(&a[2]); + + char picPath[DVX_MAX_PATH]; + + snprintf(picPath, sizeof(picPath), "%s/sigPic.bmp", tTempDir()); + a[0] = basValStringFromC(picPath); + basFormRtCallMethod(app.rt, pic, "Save", a, 1); + basFormRtCallMethod(app.rt, pic, "Load", a, 1); + basValRelease(&a[0]); + TEST_EQ(access(picPath, F_OK), 0); + callIntM(&app, pic, "Clear", (int32_t[]){ 0 }, 1); + + // Insufficient args are dispatched as no-ops / zero returns. + callIntM(&app, pic, "SetPixel", (int32_t[]){ 1 }, 1); + r = callIntM(&app, pic, "GetPixel", NULL, 0); + TEST_EQ(basValToInt32(r), 0); + r = callIntM(&app, lst, "List", NULL, 0); + TEST_TRUE(r.type == BAS_TYPE_STRING && r.strVal->len == 0); + basValRelease(&r); + r = callIntM(&app, lst, "IsItemSelected", NULL, 0); + TEST_TRUE(!basValIsTruthy(r)); + + // RET_STR_INT_INT / INT_INT_STR / STR / INT_INT on the list view. + a[0] = basValStringFromC("Id|Name"); + basFormRtCallMethod(app.rt, lv, "SetColumns", a, 1); + basValRelease(&a[0]); + a[0] = basValStringFromC("r0"); + basFormRtCallMethod(app.rt, lv, "AddItem", a, 1); + basValRelease(&a[0]); + a[0] = basValLong(0); + a[1] = basValLong(1); + a[2] = basValStringFromC("cell"); + basFormRtCallMethod(app.rt, lv, "SetCell", a, 3); + basValRelease(&a[2]); + r = callIntM(&app, lv, "GetCell", (int32_t[]){ 0, 1 }, 2); + TEST_TRUE(r.type == BAS_TYPE_STRING && strcmp(r.strVal->data, "cell") == 0); + basValRelease(&r); + r = callIntM(&app, lv, "GetCell", (int32_t[]){ 0 }, 1); + TEST_TRUE(r.type != BAS_TYPE_STRING || r.strVal->len == 0); + basValRelease(&r); + r = callIntM(&app, lv, "RowCount", NULL, 0); + TEST_EQ(basValToInt32(r), 1); + callIntM(&app, lv, "SetSort", (int32_t[]){ 0, 1 }, 2); + + // STR_STR / STR_BOOL / STR_INT on the grid. + a[0] = basValStringFromC("id"); + a[1] = basValStringFromC("ID"); + basFormRtCallMethod(app.rt, grd, "SetColumnHeader", a, 2); + basValRelease(&a[1]); + a[1] = basValBool(false); + basFormRtCallMethod(app.rt, grd, "SetColumnVisible", a, 2); + a[1] = basValLong(40); + basFormRtCallMethod(app.rt, grd, "SetColumnWidth", a, 2); + basValRelease(&a[0]); + + // STR / RET_STR / STR_BOOL_BOOL / STR_STR_BOOL / INT on the editor. + a[0] = basValStringFromC("hello world"); + basFormRtCallMethod(app.rt, ta, "AppendText", a, 1); + basValRelease(&a[0]); + callIntM(&app, ta, "GoToLine", (int32_t[]){ 1 }, 1); + r = callIntM(&app, ta, "GetWordAtCursor", NULL, 0); + TEST_TRUE(r.type == BAS_TYPE_STRING); + basValRelease(&r); + a[0] = basValStringFromC("world"); + a[1] = basValBool(true); + a[2] = basValBool(true); + r = basFormRtCallMethod(app.rt, ta, "FindNext", a, 3); + TEST_TRUE(basValIsTruthy(r)); + basValRelease(&a[0]); + a[0] = basValStringFromC("l"); + a[1] = basValStringFromC("L"); + a[2] = basValBool(false); + r = basFormRtCallMethod(app.rt, ta, "ReplaceAll", a, 3); + TEST_TRUE(basValToInt32(r) >= 1); + basValRelease(&a[0]); + basValRelease(&a[1]); + + // INT_STR / RET_STR_INT / INT_BOOL / RET_BOOL_INT / RET_INT on the tree. + a[0] = basValStringFromC("root"); + basFormRtCallMethod(app.rt, tv, "AddItem", a, 1); + basValRelease(&a[0]); + a[0] = basValLong(0); + a[1] = basValStringFromC("kid"); + basFormRtCallMethod(app.rt, tv, "AddChildItem", a, 2); + basValRelease(&a[1]); + r = callIntM(&app, tv, "GetItemText", (int32_t[]){ 1 }, 1); + TEST_TRUE(r.type == BAS_TYPE_STRING && strcmp(r.strVal->data, "kid") == 0); + basValRelease(&r); + a[0] = basValLong(0); + a[1] = basValBool(true); + basFormRtCallMethod(app.rt, tv, "SetExpanded", a, 2); + r = callIntM(&app, tv, "IsExpanded", (int32_t[]){ 0 }, 1); + TEST_TRUE(basValIsTruthy(r)); + r = callIntM(&app, tv, "ItemCount", NULL, 0); + TEST_EQ(basValToInt32(r), 2); + + // Tab pages, spinner ranges, terminal write/poll, timer start/stop. + a[0] = basValStringFromC("P1"); + basFormRtCallMethod(app.rt, tab, "AddPage", a, 1); + basValRelease(&a[0]); + a[0] = basValStringFromC("P2"); + basFormRtCallMethod(app.rt, tab, "AddPage", a, 1); + basValRelease(&a[0]); + callIntM(&app, tab, "SetActive", (int32_t[]){ 1 }, 1); + r = callIntM(&app, tab, "GetActive", NULL, 0); + TEST_EQ(basValToInt32(r), 1); + callIntM(&app, spn, "SetRange", (int32_t[]){ 0, 10 }, 2); + callIntM(&app, spn, "SetStep", (int32_t[]){ 2 }, 1); + a[0] = basValStringFromC("x"); + basFormRtCallMethod(app.rt, trm, "Write", a, 1); + basValRelease(&a[0]); + callIntM(&app, trm, "Poll", NULL, 0); + callIntM(&app, trm, "Clear", NULL, 0); + callIntM(&app, tmr, "Start", NULL, 0); + TEST_TRUE(wgtTimerIsRunning(tmr->widget)); + callIntM(&app, tmr, "Stop", NULL, 0); + TEST_TRUE(!wgtTimerIsRunning(tmr->widget)); + + // Unknown method raises and halts. + callIntM(&app, lst, "Frobnicate", NULL, 0); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + + tPump(1); + frtDestroy(&app); + tShutdown(); +} + + +static void caseMsgBox(void) { + tCase("MsgBox driven by tDialogScript"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Msg\"\n" + " Begin CommandButton Cmd1\n" + " Caption = \"Ask\"\n" + " End\n" + "End\n" + "\n" + "Sub Cmd1_Click\n" + " Dim r As Integer\n" + " r = MsgBox(\"Sure?\", 3, \"Ask\")\n" + " Print \"r=\"; r\n" + " MsgBox \"Done\"\n" + " Print \"after\"\n" + "End Sub\n"; + FrtAppT app; + DialogCtxT dlg; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *cmd = frtCtrl(form, "Cmd1"); + int32_t sx = 0; + int32_t sy = 0; + + memset(&dlg, 0, sizeof(dlg)); + snprintf(dlg.button, sizeof(dlg.button), "Yes"); + tDialogScript(dialogClick, &dlg); + tWidgetCenter(cmd->widget, &sx, &sy); + tClick(sx, sy); + tPump(1); + TEST_TRUE(dlg.seen >= 2); + TEST_STR_EQ(dlg.title, "DVX BASIC"); + TEST_STR_EQ(frtOut(&app), "r=3 \nafter\n"); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + tDialogScript(NULL, NULL); + + frtDestroy(&app); + tShutdown(); +} + + +static void casePendingUnloadDrain(void) { + tCase("deferred unloads of several forms drain after the handler"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm1 = + "Begin Form Form1\n" + " Caption = \"One\"\n" + " Begin CommandButton Go\n" + " Caption = \"Go\"\n" + " End\n" + "End\n" + "\n" + "Sub Go_Click\n" + " Unload Form2\n" + " Load Form2\n" + " Unload Form1\n" + " Print \"tail\"\n" + "End Sub\n"; + const char *frm2 = + "Begin Form Form2\n" + " Caption = \"Two\"\n" + " Begin TextBox T2\n" + " End\n" + "End\n"; + const char *frms[2] = { frm1, frm2 }; + FrtAppT app; + + if (!expectBuild(&app, "", frms, 2)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + TEST_TRUE(frtForm(&app, "Form2") != NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *go = frtCtrl(form, "Go"); + int32_t sx = 0; + int32_t sy = 0; + + // The handler unloads Form2 (deferred), re-Loads it (must get the + // pending form back, not a resurrected copy), then unloads its own + // form; the tail print proves the handler finished on live memory + // and the drain then frees both forms. + tWidgetCenter(go->widget, &sx, &sy); + tClick(sx, sy); + tPump(1); + outContains(&app, "tail"); + TEST_PTR_EQ(frtForm(&app, "Form1"), NULL); + TEST_PTR_EQ(frtForm(&app, "Form2"), NULL); + TEST_EQ((int32_t)arrlen(app.rt->forms), 0); + TEST_TRUE(!app.rt->terminated); + + frtDestroy(&app); + + // Teardown with frees still queued: basFormRtDestroy drains both + // pending queues itself. + if (!expectBuild(&app, "", frms, 2)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *f2 = frtForm(&app, "Form2"); + + TEST_TRUE(f2 != NULL); + app.rt->eventDepth++; + basFormRtRemoveCtrl(app.rt, f2, "T2"); + basFormRtUnloadForm(app.rt, f2); + app.rt->eventDepth--; + frtDestroy(&app); + tShutdown(); +} + + +static void casePopupMenu(void) { + tCase("PopupMenu on a control positions relative to it"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Pop\"\n" + " Begin Menu mnuPop\n" + " Caption = \"Popup\"\n" + " Visible = False\n" + " Begin Menu mnuPopItem\n" + " Caption = \"Pop Item\"\n" + " End\n" + " End\n" + " Begin Label Lbl1\n" + " Caption = \"spacer\"\n" + " End\n" + " Begin CommandButton Cmd1\n" + " Caption = \"Menu\"\n" + " End\n" + "End\n" + "\n" + "Sub Cmd1_Click\n" + " Cmd1.PopupMenu \"mnuPop\", 5, 7\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *cmd = frtCtrl(form, "Cmd1"); + WindowT *win = form->window; + int32_t sx = 0; + int32_t sy = 0; + + tWidgetCenter(cmd->widget, &sx, &sy); + tClick(sx, sy); + tPump(1); + TEST_TRUE(tCtx()->popup.active); + TEST_TRUE(tCtx()->popup.isContextMenu); + TEST_PTR_EQ(tCtx()->popup.menu, form->popupMenus[0].menu); + TEST_EQ(tCtx()->popup.popupX, win->x + win->contentX + cmd->widget->x + POPUP_OFF_X); + TEST_EQ(tCtx()->popup.popupY, win->y + win->contentY + cmd->widget->y + POPUP_OFF_Y); + tKeyEsc(); + tPump(1); + TEST_TRUE(!tCtx()->popup.active); + + // Without coordinates the popup opens at the mouse position. + tMouseMove(sx + 3, sy + 4); + + BasValueT menuName = basValStringFromC("mnuPop"); + + basFormRtCallMethod(app.rt, cmd, "PopupMenu", &menuName, 1); + basValRelease(&menuName); + tPump(1); + TEST_TRUE(tCtx()->popup.active); + TEST_EQ(tCtx()->popup.popupX, sx + 3); + TEST_EQ(tCtx()->popup.popupY, sy + 4); + tKeyEsc(); + tPump(1); + + frtDestroy(&app); + tShutdown(); +} + + +static void casePropEdges(void) { + tCase("form/control/menu property edges and error routing"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Prop\"\n" + " Begin Menu mnuFile\n" + " Caption = \"&File\"\n" + " Begin Menu mnuOpen\n" + " Caption = \"&Open\"\n" + " End\n" + " End\n" + " Begin Menu mnuPop\n" + " Caption = \"Pop\"\n" + " Visible = False\n" + " Begin Menu mnuPopA\n" + " Caption = \"A\"\n" + " End\n" + " End\n" + " Begin ListBox Lst\n" + " End\n" + " Begin CommandButton Cmd\n" + " Caption = \"Go\"\n" + " End\n" + "End\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *cmd = frtCtrl(form, "Cmd"); + BasControlT *lst = frtCtrl(form, "Lst"); + BasValueT v; + + // Menu proxies: Checked/Enabled/Name work, anything else raises. + BasControlT *mnu = (BasControlT *)basFormRtFindCtrl(app.rt, form, "mnuOpen"); + + TEST_TRUE(mnu != NULL && mnu->menuId > 0); + v = basFormRtGetProp(app.rt, mnu, "Checked"); + TEST_TRUE(!basValIsTruthy(v)); + basFormRtSetProp(app.rt, mnu, "Checked", basValBool(true)); + v = basFormRtGetProp(app.rt, mnu, "Checked"); + TEST_TRUE(basValIsTruthy(v)); + basFormRtSetProp(app.rt, mnu, "Enabled", basValBool(false)); + v = basFormRtGetProp(app.rt, mnu, "Enabled"); + TEST_TRUE(!basValIsTruthy(v)); + v = basFormRtGetProp(app.rt, mnu, "Name"); + TEST_TRUE(v.type == BAS_TYPE_STRING && strcasecmp(v.strVal->data, "mnuOpen") == 0); + basValRelease(&v); + basFormRtGetProp(app.rt, mnu, "Wat"); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + + // Popup-only items resolve through the popup path. + BasControlT *pop = (BasControlT *)basFormRtFindCtrl(app.rt, form, "mnuPopA"); + + TEST_TRUE(pop != NULL); + basFormRtSetProp(app.rt, pop, "Checked", basValBool(true)); + v = basFormRtGetProp(app.rt, pop, "Checked"); + TEST_TRUE(basValIsTruthy(v)); + + // Form properties: write and read every branch. + BasControlT *fc = &form->formCtrl; + + setStrProp(&app, fc, "Caption", "NewCap"); + v = basFormRtGetProp(app.rt, fc, "Caption"); + TEST_TRUE(v.type == BAS_TYPE_STRING && strcmp(v.strVal->data, "NewCap") == 0); + basValRelease(&v); + basFormRtSetProp(app.rt, fc, "Width", basValLong(360)); + basFormRtSetProp(app.rt, fc, "Height", basValLong(240)); + basFormRtSetProp(app.rt, fc, "Left", basValLong(12)); + basFormRtSetProp(app.rt, fc, "Top", basValLong(15)); + TEST_EQ(form->window->x, 12); + TEST_EQ(form->window->y, 15); + basFormRtSetProp(app.rt, fc, "Resizable", basValBool(false)); + TEST_TRUE(!form->window->resizable); + basFormRtSetProp(app.rt, fc, "Visible", basValBool(false)); + tPump(1); + TEST_TRUE(!form->window->visible); + basFormRtSetProp(app.rt, fc, "Visible", basValBool(true)); + tPump(1); + basFormRtSetProp(app.rt, fc, "Centered", basValBool(true)); + basFormRtSetProp(app.rt, fc, "AutoSize", basValBool(true)); + setStrProp(&app, fc, "HelpTopic", "hlp"); + TEST_STR_EQ(form->helpTopic, "hlp"); + setStrProp(&app, fc, "ContextMenu", "mnuPop"); + v = basFormRtGetProp(app.rt, fc, "ContextMenu"); + TEST_TRUE(v.type == BAS_TYPE_STRING && strcmp(v.strVal->data, "mnuPop") == 0); + basValRelease(&v); + setStrProp(&app, fc, "ContextMenu", ""); + TEST_PTR_EQ(form->window->contextMenu, NULL); + v = basFormRtGetProp(app.rt, fc, "Layout"); + TEST_TRUE(v.type == BAS_TYPE_STRING); + basValRelease(&v); + v = basFormRtGetProp(app.rt, fc, "Name"); + TEST_TRUE(v.type == BAS_TYPE_STRING && strcmp(v.strVal->data, "Form1") == 0); + basValRelease(&v); + v = basFormRtGetProp(app.rt, fc, "AutoSize"); + TEST_TRUE(basValIsTruthy(v)); + v = basFormRtGetProp(app.rt, fc, "Centered"); + TEST_TRUE(basValIsTruthy(v)); + v = basFormRtGetProp(app.rt, fc, "Resizable"); + TEST_TRUE(!basValIsTruthy(v)); + v = basFormRtGetProp(app.rt, fc, "Width"); + TEST_TRUE(basValToInt32(v) > 0); + v = basFormRtGetProp(app.rt, fc, "HelpTopic"); + basValRelease(&v); + + // Unknown/read-only form properties raise. + setStrProp(&app, fc, "Name", "X"); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + basFormRtGetProp(app.rt, fc, "Bogus"); + frtClearError(&app); + + // Common control properties. + basFormRtSetProp(app.rt, cmd, "Left", basValLong(3)); + basFormRtSetProp(app.rt, cmd, "Top", basValLong(4)); + basFormRtSetProp(app.rt, cmd, "Weight", basValLong(2)); + basFormRtSetProp(app.rt, cmd, "MaxWidth", basValLong(90)); + basFormRtSetProp(app.rt, cmd, "MaxHeight", basValLong(60)); + basFormRtSetProp(app.rt, cmd, "Visible", basValBool(true)); + basFormRtSetProp(app.rt, cmd, "Enabled", basValBool(false)); + basFormRtSetProp(app.rt, cmd, "ReadOnly", basValBool(true)); + basFormRtSetProp(app.rt, cmd, "BackColor", basValLong(3)); + basFormRtSetProp(app.rt, cmd, "ForeColor", basValLong(5)); + setStrProp(&app, cmd, "ToolTipText", "tip"); + setStrProp(&app, cmd, "HelpTopic", "cmdHelp"); + setStrProp(&app, cmd, "DataSource", "D"); + setStrProp(&app, cmd, "DataField", "f"); + v = basFormRtGetProp(app.rt, cmd, "Left"); + TEST_EQ(basValToInt32(v), cmd->widget->x); + v = basFormRtGetProp(app.rt, cmd, "Top"); + TEST_EQ(basValToInt32(v), cmd->widget->y); + v = basFormRtGetProp(app.rt, cmd, "Weight"); + TEST_EQ(basValToInt32(v), 2); + v = basFormRtGetProp(app.rt, cmd, "MaxWidth"); + TEST_EQ(basValToInt32(v), 90); + v = basFormRtGetProp(app.rt, cmd, "MaxHeight"); + TEST_EQ(basValToInt32(v), 60); + v = basFormRtGetProp(app.rt, cmd, "MinWidth"); + TEST_TRUE(basValToInt32(v) >= 0); + v = basFormRtGetProp(app.rt, cmd, "Enabled"); + TEST_TRUE(!basValIsTruthy(v)); + v = basFormRtGetProp(app.rt, cmd, "ReadOnly"); + TEST_TRUE(basValIsTruthy(v)); + v = basFormRtGetProp(app.rt, cmd, "BackColor"); + TEST_EQ(basValToInt32(v), 3); + v = basFormRtGetProp(app.rt, cmd, "ForeColor"); + TEST_EQ(basValToInt32(v), 5); + v = basFormRtGetProp(app.rt, cmd, "ToolTipText"); + TEST_TRUE(v.type == BAS_TYPE_STRING && strcmp(v.strVal->data, "tip") == 0); + basValRelease(&v); + v = basFormRtGetProp(app.rt, cmd, "HelpTopic"); + TEST_TRUE(v.type == BAS_TYPE_STRING && strcmp(v.strVal->data, "cmdHelp") == 0); + basValRelease(&v); + v = basFormRtGetProp(app.rt, cmd, "DataSource"); + TEST_TRUE(v.type == BAS_TYPE_STRING && strcmp(v.strVal->data, "D") == 0); + basValRelease(&v); + v = basFormRtGetProp(app.rt, cmd, "DataField"); + basValRelease(&v); + v = basFormRtGetProp(app.rt, cmd, "Name"); + TEST_TRUE(v.type == BAS_TYPE_STRING && strcmp(v.strVal->data, "Cmd") == 0); + basValRelease(&v); + + // ListCount as a common property: real count on a list widget, + // zero on a widget without item storage. + BasValueT item = basValStringFromC("x"); + + basFormRtCallMethod(app.rt, lst, "AddItem", &item, 1); + basValRelease(&item); + v = basFormRtGetProp(app.rt, lst, "ListCount"); + TEST_EQ(basValToInt32(v), 1); + v = basFormRtGetProp(app.rt, cmd, "ListCount"); + TEST_EQ(basValToInt32(v), 0); + + // Control ContextMenu binds by popup name. + setStrProp(&app, cmd, "ContextMenu", "mnuPop"); + v = basFormRtGetProp(app.rt, cmd, "ContextMenu"); + TEST_TRUE(v.type == BAS_TYPE_STRING && strcmp(v.strVal->data, "mnuPop") == 0); + basValRelease(&v); + + // Unknown property and unknown control routes. + basFormRtGetProp(app.rt, cmd, "Bogus"); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + basFormRtSetProp(app.rt, cmd, "Bogus", basValLong(1)); + frtClearError(&app); + basFormRtGetProp(app.rt, NULL, "X"); + frtClearError(&app); + basFormRtSetProp(app.rt, NULL, "X", basValLong(1)); + frtClearError(&app); + basFormRtCallMethod(app.rt, NULL, "X", NULL, 0); + frtClearError(&app); + + // A method call on a menu proxy (no widget behind it) raises. + basFormRtCallMethod(app.rt, mnu, "SetFocus", NULL, 0); + frtClearError(&app); + + // Menu property writes only accept Checked/Enabled. + setStrProp(&app, mnu, "Wat", "x"); + frtClearError(&app); + + // ENUM properties by name, by number, and with a bad name; INT + // setters and a read-only interface property. + BasControlT *lbl = (BasControlT *)basFormRtCreateCtrl(app.rt, form, "Label", "Lbl1"); + BasControlT *spn = (BasControlT *)basFormRtCreateCtrl(app.rt, form, "SpinButton", "Spn1"); + BasControlT *ta = (BasControlT *)basFormRtCreateCtrl(app.rt, form, "TextArea", "Ta1"); + + TEST_TRUE(lbl != NULL && spn != NULL && ta != NULL); + setStrProp(&app, lbl, "Alignment", "Center"); + v = basFormRtGetProp(app.rt, lbl, "Alignment"); + TEST_EQ(basValToInt32(v), 1); + basFormRtSetProp(app.rt, lbl, "Alignment", basValLong(2)); + v = basFormRtGetProp(app.rt, lbl, "Alignment"); + TEST_EQ(basValToInt32(v), 2); + setStrProp(&app, lbl, "Alignment", "Wat"); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + basFormRtSetProp(app.rt, spn, "Value", basValLong(5)); + v = basFormRtGetProp(app.rt, spn, "Value"); + TEST_EQ(basValToInt32(v), 5); + basFormRtSetProp(app.rt, ta, "CursorLine", basValLong(3)); + TEST_TRUE(!app.rt->terminated); + v = basFormRtGetProp(app.rt, ta, "CursorLine"); + TEST_TRUE(basValToInt32(v) >= 0); + + // Dynamic popup menus through the common-method surface, including + // the argument guards and the contextMenu purge on DestroyMenu. + BasControlT *fcm = &form->formCtrl; + BasValueT ma[3]; + + ma[0] = basValStringFromC("dynPop"); + basFormRtCallMethod(app.rt, fcm, "CreateMenu", ma, 1); + basFormRtCallMethod(app.rt, fcm, "CreateMenu", ma, 1); + basFormRtCallMethod(app.rt, fcm, "CreateMenu", NULL, 0); + ma[1] = basValStringFromC("dynIt"); + ma[2] = basValStringFromC("Item"); + basFormRtCallMethod(app.rt, fcm, "AddMenuItem", ma, 3); + basFormRtCallMethod(app.rt, fcm, "AddMenuItem", ma, 1); + basValRelease(&ma[1]); + basValRelease(&ma[2]); + ma[1] = basValStringFromC("dynSub"); + ma[2] = basValStringFromC("Sub"); + basFormRtCallMethod(app.rt, fcm, "AddSubMenu", ma, 3); + basFormRtCallMethod(app.rt, fcm, "AddSubMenu", ma, 1); + basFormRtCallMethod(app.rt, fcm, "AddMenuSeparator", ma, 1); + basFormRtCallMethod(app.rt, fcm, "AddMenuSeparator", NULL, 0); + basValRelease(&ma[0]); + basValRelease(&ma[1]); + basValRelease(&ma[2]); + ma[0] = basValStringFromC("noSuchMenu"); + basFormRtCallMethod(app.rt, fcm, "PopupMenu", ma, 1); + basFormRtCallMethod(app.rt, fcm, "AddMenuSeparator", ma, 1); + basValRelease(&ma[0]); + basFormRtCallMethod(app.rt, fcm, "PopupMenu", NULL, 0); + basFormRtCallMethod(app.rt, fcm, "DestroyMenu", NULL, 0); + setStrProp(&app, cmd, "ContextMenu", "dynPop"); + setStrProp(&app, fc, "ContextMenu", "dynPop"); + ma[0] = basValStringFromC("dynSub"); + basFormRtCallMethod(app.rt, fcm, "DestroyMenu", ma, 1); + basValRelease(&ma[0]); + ma[0] = basValStringFromC("dynPop"); + basFormRtCallMethod(app.rt, fcm, "DestroyMenu", ma, 1); + basValRelease(&ma[0]); + TEST_PTR_EQ(cmd->widget->contextMenu, NULL); + TEST_PTR_EQ(form->window->contextMenu, NULL); + + // Destroying the .frm popup leaves its proxy widgetless: reads on it + // now raise the no-widget error. + ma[0] = basValStringFromC("mnuPop"); + basFormRtCallMethod(app.rt, fcm, "DestroyMenu", ma, 1); + basValRelease(&ma[0]); + basFormRtGetProp(app.rt, pop, "Checked"); + frtClearError(&app); + + frtDestroy(&app); + tShutdown(); +} + + +static void casePropTables(void) { + tCase("property tables: readable/writable exactly as declared"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Props\"\n" + " Begin ListBox Lst1\n" + " End\n" + " Begin Timer Tmr1\n" + " Enabled = False\n" + " End\n" + "End\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *lst = frtCtrl(form, "Lst1"); + BasControlT *tmr = frtCtrl(form, "Tmr1"); + int32_t count = 0; + + app.rt->suppressErrorDialog = true; + + // Form table. + const BasPropDescT *props = basFormRtFormProps(&count); + + for (int32_t i = 0; i < count; i++) { + const BasPropDescT *pd = &props[i]; + BasValueT v = basFormRtGetProp(app.rt, &form->formCtrl, pd->name); + + TEST_EQ(app.rt->terminated, !pd->readable); + frtClearError(&app); + basFormRtSetProp(app.rt, &form->formCtrl, pd->name, v); + TEST_EQ(app.rt->terminated, !pd->writable); + frtClearError(&app); + basValRelease(&v); + TEST_PTR_EQ(basFormRtFindFormProp(pd->name), pd); + } + + TEST_PTR_EQ(basFormRtFindFormProp("NoSuchProp"), NULL); + basFormRtGetProp(app.rt, &form->formCtrl, "NoSuchProp"); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + basFormRtSetProp(app.rt, &form->formCtrl, "NoSuchProp", basValLong(1)); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + + // Common control table on a widget without overrides. + props = basFormRtCommonProps(&count); + + for (int32_t i = 0; i < count; i++) { + const BasPropDescT *pd = &props[i]; + BasValueT v = basFormRtGetProp(app.rt, lst, pd->name); + + TEST_EQ(app.rt->terminated, !pd->readable); + frtClearError(&app); + basFormRtSetProp(app.rt, lst, pd->name, v); + TEST_EQ(app.rt->terminated, !pd->writable); + frtClearError(&app); + basValRelease(&v); + TEST_PTR_EQ(basFormRtFindCommonProp(pd->name), pd); + } + + TEST_PTR_EQ(basFormRtFindCommonProp("NoSuchProp"), NULL); + basFormRtGetProp(app.rt, lst, "NoSuchProp"); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + basFormRtSetProp(app.rt, lst, "NoSuchProp", basValLong(1)); + TEST_TRUE(app.rt->terminated); + frtClearError(&app); + + // ListCount reads through the widget's ListCount method. + BasValueT item = basValStringFromC("one"); + + basFormRtCallMethod(app.rt, lst, "AddItem", &item, 1); + basFormRtCallMethod(app.rt, lst, "AddItem", &item, 1); + basValRelease(&item); + + BasValueT lc = basFormRtGetProp(app.rt, lst, "ListCount"); + + TEST_EQ((int32_t)basValToNumber(lc), 2); + basValRelease(&lc); + + // A widget-declared property shadows the common one: Timer.Enabled + // is the running state, not wgtSetEnabled. + basFormRtSetProp(app.rt, tmr, "Enabled", basValBool(true)); + TEST_TRUE(wgtTimerIsRunning(tmr->widget)); + + BasValueT en = basFormRtGetProp(app.rt, tmr, "Enabled"); + + TEST_TRUE(basValIsTruthy(en)); + basValRelease(&en); + basFormRtSetProp(app.rt, tmr, "Enabled", basValBool(false)); + TEST_TRUE(!wgtTimerIsRunning(tmr->widget)); + + // Menu proxies expose Checked/Enabled/Name only. + TEST_TRUE(!app.rt->terminated); + frtDestroy(&app); + tShutdown(); +} + + +static void caseQueryUnloadCancel(void) { + tCase("QueryUnload cancel keeps the form"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"QU\"\n" + "End\n" + "\n" + "Dim allow As Integer\n" + "Sub Form1_QueryUnload(Cancel As Integer)\n" + " If allow = 0 Then Cancel = 1\n" + " Print \"qu=\"; Cancel\n" + "End Sub\n" + "Sub Form1_Unload\n" + " Print \"unload\"\n" + "End Sub\n" + "Sub Form1_Allow\n" + " allow = 1\n" + "End Sub\n"; + const char *bas = + "Unload Form1\n" + "Print \"still=\"; Form1.Visible\n"; + FrtAppT app; + + if (!expectBuild(&app, bas, &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + + TEST_EQ(app.mainResult, BAS_VM_HALTED); + TEST_STR_EQ(frtOut(&app), "qu=1 \nstill=True \n"); + TEST_TRUE(form != NULL); + TEST_TRUE(form && !form->unloading); + + // The close box takes the same path. + frtOutClear(&app); + tAltF4(); + tPump(1); + TEST_STR_EQ(frtOut(&app), "qu=1 \n"); + TEST_PTR_EQ(frtForm(&app, "Form1"), form); + + basFormRtFireEvent(app.rt, form, "Form1", "Allow"); + frtOutClear(&app); + tAltF4(); + tPump(1); + TEST_STR_EQ(frtOut(&app), "qu=0 \nunload\n"); + TEST_PTR_EQ(frtForm(&app, "Form1"), NULL); + TEST_PTR_EQ(tFindWindow("QU"), NULL); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseRemoveContainerFromHandler(void) { + tCase("RemoveControl on a container from a child's own Click"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Rm\"\n" + " Begin Frame Fr1\n" + " Begin HBox Row1\n" + " Begin CommandButton Cmd1\n" + " Caption = \"Remove\"\n" + " End\n" + " Begin Label Lbl1\n" + " Caption = \"x\"\n" + " End\n" + " End\n" + " End\n" + " Begin Label Lbl2\n" + " Caption = \"stays\"\n" + " End\n" + "End\n" + "\n" + "Sub Cmd1_Click\n" + " RemoveControl Me, \"Fr1\"\n" + " Print \"removed \"; Lbl2.Caption\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *cmd = frtCtrl(form, "Cmd1"); + int32_t sx = 0; + int32_t sy = 0; + + TEST_EQ((int32_t)arrlen(form->controls), 5); + tWidgetCenter(cmd->widget, &sx, &sy); + tClick(sx, sy); + tPump(2); + TEST_STR_EQ(frtOut(&app), "removed stays\n"); + TEST_EQ((int32_t)arrlen(form->controls), 1); + TEST_PTR_EQ(frtCtrl(form, "Fr1"), NULL); + TEST_PTR_EQ(frtCtrl(form, "Cmd1"), NULL); + TEST_EQ((int32_t)arrlen(app.rt->pendingCtrlFree), 0); + TEST_TRUE(frtCtrl(form, "Lbl2") != NULL); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseResetCycles(void) { + tCase("basFormRtTestReset across 50 init/shutdown cycles"); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Cycle\"\n" + " Begin Menu mnuPop\n" + " Caption = \"Popup\"\n" + " Visible = False\n" + " Begin Menu mnuPopItem\n" + " Caption = \"Item\"\n" + " End\n" + " End\n" + " Begin TextBox Txt1\n" + " End\n" + " Begin CommandButton Cmd1\n" + " Caption = \"Go\"\n" + " ContextMenu = \"mnuPop\"\n" + " End\n" + " Begin Timer Tmr1\n" + " Interval = 100\n" + " Enabled = True\n" + " End\n" + " Begin Terminal Term1\n" + " End\n" + "End\n" + "\n" + "Sub Cmd1_Click\n" + " Print \"click\"\n" + " Unload Form1\n" + "End Sub\n" + "Sub Tmr1_Timer\n" + " Print \"tick\"\n" + "End Sub\n"; + int32_t okCycles = 0; + + for (int32_t i = 0; i < RESET_CYCLES; i++) { + FrtAppT app; + + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + break; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *cmd = frtCtrl(form, "Cmd1"); + int32_t sx = 0; + int32_t sy = 0; + + SerAttach(1, "Term1"); + tAdvanceClock(TIMER_INTERVAL_MS); + tWidgetCenter(cmd->widget, &sx, &sy); + tClick(sx, sy); + tPump(1); + + bool ok = strcmp(frtOut(&app), "tick\nclick\n") == 0 && frtForm(&app, "Form1") == NULL; + + frtDestroy(&app); + tShutdown(); + + if (!ok) { + break; + } + + okCycles++; + } + + TEST_EQ(okCycles, RESET_CYCLES); +} + + +static void caseResources(void) { + tCase("resource files: add, open handles, read, extract, remove"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + char resPath[DVX_MAX_PATH]; + char srcPath[DVX_MAX_PATH]; + char outPath[DVX_MAX_PATH]; + + snprintf(resPath, sizeof(resPath), "%s/case.res", tTempDir()); + snprintf(srcPath, sizeof(srcPath), "%s/res_src.bin", tTempDir()); + snprintf(outPath, sizeof(outPath), "%s/res_out.bin", tTempDir()); + unlink(resPath); + TEST_TRUE(tWriteFile(srcPath, "BINDATA", 7)); + + // The appender extends an existing file; seed an empty one. + TEST_TRUE(tWriteFile(resPath, "", 0)); + + // Argument guards. + TEST_EQ(ResAddText(NULL, "n", "t"), 0); + TEST_EQ(ResAddFile(resPath, "n", 3, "/no/such/file"), 0); + TEST_EQ(ResOpen(NULL), 0); + TEST_EQ(ResOpen("/no/such.res"), 0); + TEST_EQ(ResExtract(resPath, "n", NULL), 0); + TEST_EQ(ResRemove(NULL, "n"), 0); + + // Build a two-entry resource file. + TEST_EQ(ResAddText(resPath, "greet", "hello"), -1); + TEST_EQ(ResAddText(resPath, "empty", NULL), -1); + TEST_EQ(ResAddFile(resPath, "blob", 3, srcPath), -1); + + int32_t h = ResOpen(resPath); + + TEST_EQ(h, 1); + TEST_EQ(ResCount(h), 3); + + // The directory is sorted by name at open: blob, empty, greet. + TEST_STR_EQ(ResName(h, 2), "greet"); + TEST_TRUE(ResSize(h, 2) > 0); + TEST_EQ(ResType(h, 0), 3); + TEST_STR_EQ(ResName(h, 99), ""); + TEST_EQ(ResSize(h, -1), 0); + TEST_EQ(ResType(h, 99), 0); + TEST_EQ(ResCount(0), 0); + TEST_STR_EQ(ResName(9, 0), ""); + ResClose(0); + ResClose(9); + + TEST_STR_EQ(ResGetText(resPath, "greet"), "hello"); + TEST_STR_EQ(ResGetText(resPath, "missing"), ""); + TEST_STR_EQ(ResGetText(NULL, "greet"), ""); + TEST_STR_EQ(ResGetText("/no/such.res", "greet"), ""); + + TEST_EQ(ResExtract(resPath, "blob", outPath), -1); + + int32_t outSize = 0; + uint8_t *outData = (uint8_t *)platformReadFile(outPath, &outSize); + + TEST_TRUE(outData != NULL && outSize == 7 && memcmp(outData, "BINDATA", 7) == 0); + free(outData); + TEST_EQ(ResExtract(resPath, "missing", outPath), 0); + TEST_EQ(ResExtract("/no/such.res", "blob", outPath), 0); + + TEST_EQ(ResRemove(resPath, "blob"), -1); + TEST_EQ(ResExtract(resPath, "greet", "/no/dir/x.out"), 0); + + // basFormRtTestReset closes every open resource handle. + TEST_EQ(ResOpen(resPath), 2); + basFormRtTestReset(); + TEST_EQ(ResCount(2), 0); + + // Fill every handle slot from a clean table; the ninth open fails. + int32_t handles[RES_HANDLE_PROBE]; + int32_t got = 0; + + while (got < RES_HANDLE_PROBE) { + handles[got] = ResOpen(resPath); + + if (handles[got] == 0) { + break; + } + + got++; + } + + TEST_EQ(got, RES_HANDLE_PROBE - 1); + TEST_EQ(ResOpen(resPath), 0); + + for (int32_t i = 0; i < got; i++) { + ResClose(handles[i]); + } + + unlink(resPath); + unlink(srcPath); + unlink(outPath); + tShutdown(); +} + + +static void caseRoundTrip(void) { + tCase(".frm round trip for every widget interface"); + + int32_t ifaceCount = wgtIfaceCount(); + int32_t tested = 0; + + for (int32_t i = 0; i < ifaceCount; i++) { + const char *wgtName = NULL; + const WgtIfaceT *iface = wgtIfaceAt(i, &wgtName); + + if (!iface || !iface->basName || strcasecmp(iface->basName, "TabPage") == 0) { + continue; + } + + printf(" round trip: %s\n", iface->basName); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + bool keepsText = frtWidgetKeepsText(wgtName); + char *frm = (char *)calloc(1, FRM_TEXT_MAX); + char *back = (char *)calloc(1, FRM_TEXT_MAX); + int32_t pos = 0; + + pos += snprintf(frm + pos, FRM_TEXT_MAX - pos, "%s%s", RT_FORM_HEADER, RT_FORM_MENUS); + pos += snprintf(frm + pos, FRM_TEXT_MAX - pos, " Begin %s C1\n", iface->basName); + + if (keepsText) { + pos += snprintf(frm + pos, FRM_TEXT_MAX - pos, " Caption = \"Hello %s\"\n", iface->basName); + } + + for (int32_t p = 0; p < iface->propCount; p++) { + const WgtPropDescT *pd = &iface->props[p]; + char val[128]; + + if (!pd->getFn || !pd->setFn) { + continue; + } + + const char *v = roundTripValue(iface->basName, pd, val, sizeof(val)); + + if (pd->type == WGT_IFACE_STRING) { + pos += snprintf(frm + pos, FRM_TEXT_MAX - pos, " %s = \"%s\"\n", pd->name, v); + } else { + pos += snprintf(frm + pos, FRM_TEXT_MAX - pos, " %s = %s\n", pd->name, v); + } + } + + // Common writable set (skipping names the interface shadows). + static const char *const common[] = { + "Weight = 2", + "MaxWidth = 150", + "MaxHeight = 90", + "Visible = True", + "Enabled = True", + "ReadOnly = True", + "BackColor = 255", + "ForeColor = 65280", + "ToolTipText = \"Tip\"", + "HelpTopic = \"topic1\"", + "ContextMenu = \"mnuPop\"", + "DataSource = \"Dat1\"", + "DataField = \"fld\"", + }; + + for (int32_t c = 0; c < (int32_t)(sizeof(common) / sizeof(common[0])); c++) { + char key[32]; + + sscanf(common[c], "%31s", key); + + if (wgtIfaceFindProp(iface, key)) { + continue; + } + + pos += snprintf(frm + pos, FRM_TEXT_MAX - pos, " %s\n", common[c]); + } + + if (strcasecmp(iface->basName, "TabStrip") == 0) { + pos += snprintf(frm + pos, FRM_TEXT_MAX - pos, + " Begin TabPage P1\n" + " Caption = \"Page\"\n" + " Weight = 0\n" + " MaxWidth = 0\n" + " MaxHeight = 0\n" + " Visible = True\n" + " Enabled = True\n" + " ReadOnly = False\n" + " BackColor = 0\n" + " ForeColor = 0\n" + " ToolTipText = \"\"\n" + " HelpTopic = \"\"\n" + " ContextMenu = \"\"\n" + " DataSource = \"\"\n" + " DataField = \"\"\n" + " End\n"); + } + + pos += snprintf(frm + pos, FRM_TEXT_MAX - pos, " End\nEnd\n"); + + FrtAppT app; + + if (expectBuild(&app, "", (const char *const *)&frm, 1)) { + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + + if (!form || app.rt->terminated) { + printf(" %s: load failed (%s)\n", iface->basName, app.vm->errorMsg); + TEST_TRUE(form && !app.rt->terminated); + } else { + frtSerializeForm(form, back, FRM_TEXT_MAX); + + // The serializer writes no menus; splice the expected + // text without the menu block. + char *expect = (char *)calloc(1, FRM_TEXT_MAX); + char *ctrls = strstr(frm, RT_FORM_MENUS) + strlen(RT_FORM_MENUS); + + snprintf(expect, FRM_TEXT_MAX, "%s%s", RT_FORM_HEADER, ctrls); + + if (strcmp(back, expect) != 0) { + printf(" %s round trip differs\n--- expected ---\n%s--- actual ---\n%s", iface->basName, expect, back); + } + + TEST_TRUE(strcmp(back, expect) == 0); + tested++; + free(expect); + } + + frtDestroy(&app); + } + + free(frm); + free(back); + tShutdown(); + } + + printf(" round-tripped %d widget interfaces\n", (int)tested); + TEST_TRUE(tested >= 29); +} + + +static void caseRunSimple(void) { + tCase("basFormRtRunSimple: slices, event loop, error dialog"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + basFormRtRunSimple(NULL); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Run\"\n" + " Begin CommandButton Quit\n" + " Caption = \"Quit\"\n" + " End\n" + "End\n" + "\n" + "Sub Quit_Click\n" + " Unload Me\n" + "End Sub\n"; + const char *bas = + "DIM i AS LONG\n" + "DIM n AS LONG\n" + "FOR i = 1 TO 30000\n" + " n = n + 1\n" + "NEXT i\n" + "PRINT \"main\"; n\n"; + FrtAppT app; + + if (!expectBuild(&app, bas, &frm, 1)) { + tShutdown(); + return; + } + + basFormRtLoadAllForms(app.rt, "Form1"); + tPump(1); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *quit = frtCtrl(form, "Quit"); + int32_t sx = 0; + int32_t sy = 0; + + tWidgetCenter(quit->widget, &sx, &sy); + hostMousePush(sx, sy, 0); + hostMousePush(sx, sy, MOUSE_LEFT); + hostMousePush(sx, sy, 0); + basFormRtRunSimple(app.rt); + outContains(&app, "main30000"); + TEST_EQ((int32_t)arrlen(app.rt->forms), 0); + frtDestroy(&app); + + // A VM-internal runtime error surfaces the generic error box (the + // harness dismisses it) and stops the run. + const char *basErr = "DIM a AS INTEGER\nDIM b AS INTEGER\nb = 0\na = 1 \\ b\nPRINT \"unreached\"\n"; + + if (!expectBuild(&app, basErr, &frm, 1)) { + tShutdown(); + return; + } + + basFormRtLoadAllForms(app.rt, "Form1"); + tPump(1); + + int32_t dismissedBefore = app.dialogsDismissed; + + form = frtForm(&app, "Form1"); + quit = frtCtrl(form, "Quit"); + tWidgetCenter(quit->widget, &sx, &sy); + hostMousePush(sx, sy, 0); + hostMousePush(sx, sy, MOUSE_LEFT); + hostMousePush(sx, sy, 0); + basFormRtRunSimple(app.rt); + TEST_TRUE(app.dialogsDismissed > dismissedBefore); + TEST_TRUE(strstr(frtOut(&app), "unreached") == NULL); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseRuntimeError(void) { + tCase("runtime error routes to the error dialog and halts"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Err\"\n" + " Begin CommandButton Cmd1\n" + " Caption = \"Go\"\n" + " End\n" + "End\n" + "\n" + "Sub Form1_Test\n" + " Print \"before\"\n" + " Cmd1.NoSuchProperty = 1\n" + " Print \"after\"\n" + "End Sub\n" + "Sub Form1_Bogus\n" + " Cmd1.Bogus\n" + "End Sub\n"; + FrtAppT app; + DialogCtxT dlg; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + memset(&dlg, 0, sizeof(dlg)); + snprintf(dlg.button, sizeof(dlg.button), "OK"); + frtStart(&app, NULL); + tDialogScript(dialogClick, &dlg); + frtFire(&app, "Form1", "Test"); + tDialogScript(NULL, NULL); + TEST_TRUE(app.vm->ended); + TEST_TRUE(app.rt->terminated); + TEST_EQ(dlg.seen >= 1, true); + TEST_STR_EQ(dlg.title, "BASIC Runtime Error"); + TEST_STR_EQ(frtOut(&app), "before\n"); + TEST_TRUE(strstr(basVmGetError(app.vm), "Property not found on control") != NULL); + TEST_TRUE(strstr(basVmGetError(app.vm), "NoSuchProperty") != NULL); + TEST_PTR_EQ(tCtx()->modalWindow, NULL); + + // Follow-on errors are swallowed while terminated: no second dialog. + dlg.seen = 0; + basFormRtRuntimeError(app.rt, "again", "x"); + TEST_EQ(dlg.seen, 0); + + // A host that suppresses the dialog still halts and records the text. + frtDestroy(&app); + tPump(1); + + if (expectBuild(&app, "", &frm, 1)) { + frtStart(&app, NULL); + app.rt->suppressErrorDialog = true; + dlg.seen = 0; + tDialogScript(dialogClick, &dlg); + frtFire(&app, "Form1", "Bogus"); + tDialogScript(NULL, NULL); + TEST_TRUE(app.vm->ended); + TEST_TRUE(app.rt->terminated); + TEST_EQ(dlg.seen, 0); + TEST_TRUE(strstr(basVmGetError(app.vm), "Method not found on control") != NULL); + frtDestroy(&app); + } + + tShutdown(); +} + + +static void caseSerialLoopback(void) { + tCase("Comm/Ser through the stub serial library"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const FrtStubApiT *api = stubsOpen(); + + if (!api) { + TEST_TRUE(api != NULL); + tShutdown(); + return; + } + + basFormRtTestReset(); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Ser\"\n" + " Begin Terminal Term1\n" + " End\n" + " Begin Terminal Term2\n" + " End\n" + "End\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + basFormRtTestReset(); + stubsClose(); + tShutdown(); + return; + } + + frtStart(&app, NULL); + + // Raw serial: open, attach, write, loop bytes back, poll via the + // stub idle registry, close. + TEST_EQ(SerOpen(1, 9600, 8, "N", 1, 0), -1); + TEST_TRUE(api->serIsOpen(0)); + SerAttach(1, "Term1"); + TEST_TRUE(api->idleCount() >= 1); + TEST_EQ(SerWrite(1, "hi"), -1); + + int32_t txLen = 0; + const uint8_t *tx = api->serTx(0, &txLen); + + TEST_TRUE(txLen == 2 && memcmp(tx, "hi", 2) == 0); + TEST_EQ(SerWrite(1, ""), -1); + api->serPush(0, "echo", 4); + TEST_EQ(SerAvailable(1), 4); + api->idleRun(); + tPump(1); + TEST_EQ(SerAvailable(1), 0); + api->serPush(0, "rd", 2); + TEST_STR_EQ(SerRead(1), "rd"); + SerFlush(1); + TEST_EQ(SerGetBase(1), SER_STUB_BASE); + TEST_EQ(SerGetIrq(1), SER_STUB_IRQ); + TEST_EQ(SerGetUart(1), SER_STUB_UART); + SerSetBase(1, 0x2F8); + TEST_EQ(SerGetBase(1), 0x2F8); + SerSetIrq(1, 3); + TEST_EQ(SerGetIrq(1), 3); + SerAttach(9, "Term1"); + SerAttach(1, "NoSuchTerm"); + + // Keystrokes into a serial-attached terminal go out through the port. + BasFormT *serForm = frtForm(&app, "Form1"); + BasControlT *term1 = frtCtrl(serForm, "Term1"); + + callIntM(&app, term1, "SetFocus", NULL, 0); + tPump(1); + tType("s"); + tPump(1); + tx = api->serTx(0, &txLen); + TEST_TRUE(txLen >= 1 && tx[txLen - 1] == 's'); + + // Re-attaching the port to another terminal unbinds the first, and + // closing an attached port detaches on the way out. + SerDetach(1); + SerAttach(1, "Term1"); + SerAttach(1, "Term2"); + SerClose(1); + api->serFail(true, false); + TEST_EQ(SerOpen(2, 9600, 8, "N", 1, 0), 0); + api->serFail(false, false); + + // Comm attach over a serial-bound terminal drops the serial binding. + TEST_EQ(SerOpen(1, 9600, 8, "N", 1, 0), -1); + SerAttach(1, "Term1"); + + // secLink: open a handle, attach the terminal on a channel, send + // (the stub loops the payload back), receive from the ring buffer. + int32_t h = CommOpen(1, 9600, 8, "N", 1, 0); + + TEST_EQ(h, 1); + CommAttach(h, "Term1", 2, 1); + CommAttach(h, "Term2", 2, 1); + CommAttach(h, "Term1", 2, 1); + CommAttach(h, "Term1", SECLINK_NUM_CHANNELS, 0); + api->idleRun(); + TEST_EQ(CommSend(h, "ping", 2, 1), -1); + + int32_t sentLen = 0; + uint8_t sentChannel = 0; + bool sentEnc = false; + const uint8_t *sent = api->commSent(&sentLen, &sentChannel, &sentEnc); + + TEST_TRUE(sentLen == 4 && memcmp(sent, "ping", 4) == 0); + TEST_EQ(sentChannel, 2); + TEST_TRUE(sentEnc); + TEST_STR_EQ(CommRecv(h, 2), "ping"); + TEST_STR_EQ(CommRecv(h, 2), ""); + TEST_STR_EQ(CommRecv(h, -1), ""); + api->commInject("inj", 3, 5); + TEST_STR_EQ(CommRecv(h, 5), "inj"); + TEST_EQ(CommSend(h, "", 0, 0), -1); + TEST_EQ(CommSend(h, "x", SECLINK_NUM_CHANNELS, 0), 0); + TEST_EQ(CommPending(h), 0); + TEST_EQ(CommPoll(h), 0); + TEST_EQ(CommIsReady(h), -1); + TEST_EQ(CommHandshake(h), -1); + api->commHandshakeRc(1); + TEST_EQ(CommHandshake(h), 0); + + // Keystrokes routed out through the attached terminal. + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *term = frtCtrl(form, "Term1"); + + callIntM(&app, term, "SetFocus", NULL, 0); + tPump(1); + tType("k"); + tPump(1); + sent = api->commSent(&sentLen, &sentChannel, &sentEnc); + TEST_TRUE(sentLen == 1 && sent[0] == 'k'); + TEST_EQ(sentChannel, 2); + + // Removing a comm-attached terminal control detaches its binding. + basFormRtRemoveCtrl(app.rt, serForm, "Term2"); + tPump(1); + + CommDetach(h); + CommClose(h); + TEST_EQ(CommIsReady(h), 0); + + // Failure injection and slot-hole reuse. + api->commFail(true, false); + TEST_EQ(CommOpen(1, 9600, 8, "N", 1, 0), 0); + api->commFail(false, false); + h = CommOpen(1, 9600, 8, NULL, 1, 0); + TEST_EQ(h, 1); + api->commFail(false, true); + TEST_EQ(CommSend(h, "y", 0, 0), 0); + api->commFail(false, false); + CommClose(h); + + // Shutdown with a live connection and an attached port closes both. + TEST_EQ(CommOpen(1, 9600, 8, "N", 1, 0), 1); + CommAttach(1, "Term1", 0, 0); + SerAttach(1, "Term1"); + basFormRtSerialShutdown(); + frtDestroy(&app); + basFormRtTestReset(); + stubsClose(); + tShutdown(); +} + + +static void caseSerialUnresolved(void) { + tCase("Comm/Ser attach and detach with the serial API unresolved"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Ser\"\n" + " Begin Terminal Term1\n" + " End\n" + "End\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *term = frtCtrl(form, "Term1"); + + TEST_TRUE(term != NULL); + TEST_EQ(SerOpen(1, 9600, 8, "N", 1, 0), 0); + SerAttach(1, "Term1"); + SerAttach(9, "Term1"); + SerAttach(1, "NoSuchTerm"); + TEST_EQ(SerWrite(1, "hello"), 0); + TEST_STR_EQ(SerRead(1), ""); + TEST_EQ(SerAvailable(1), 0); + SerDetach(1); + SerDetach(0); + SerClose(1); + + TEST_EQ(CommOpen(1, 9600, 8, "N", 1, 0), 0); + CommAttach(1, "Term1", 0, 0); + CommAttach(0, "Term1", 0, 0); + TEST_EQ(CommSend(1, "hello", 0, 0), 0); + CommDetach(1); + CommClose(1); + CommClose(0); + basFormRtSerialShutdown(); + TEST_TRUE(!app.rt->terminated); + tPump(1); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseSetEvent(void) { + tCase("SetEvent retargets a control's event"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"SE\"\n" + " Begin CommandButton Cmd1\n" + " Caption = \"Go\"\n" + " End\n" + "End\n" + "\n" + "Sub Cmd1_Click\n" + " Print \"default\"\n" + "End Sub\n" + "Sub AltClick\n" + " Print \"alt\"\n" + "End Sub\n" + "Sub AltClick2\n" + " Print \"alt2\"\n" + "End Sub\n" + "Sub DynClick\n" + " Print \"dyn \"; Cmd2.Caption\n" + "End Sub\n" + "Sub Form1_Load\n" + " Dim c As Long\n" + " Set c = CreateControl(Me, \"CommandButton\", \"Cmd2\")\n" + " Cmd2.Caption = \"Made\"\n" + " SetEvent c, \"Click\", \"DynClick\"\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *cmd = frtCtrl(form, "Cmd1"); + BasControlT *cmd2 = frtCtrl(form, "Cmd2"); + int32_t sx = 0; + int32_t sy = 0; + + TEST_EQ(app.mainResult, BAS_VM_HALTED); + TEST_TRUE(cmd && cmd2); + + if (!(cmd && cmd2)) { + frtDestroy(&app); + tShutdown(); + return; + } + + tPump(1); + tWidgetCenter(cmd->widget, &sx, &sy); + tClick(sx, sy); + TEST_STR_EQ(frtOut(&app), "default\n"); + + // Retarget, retarget again (update in place), then to a missing SUB + // (consumes the event: nothing fires, no fallback). + frtOutClear(&app); + basFormRtSetEvent(app.rt, cmd, "Click", "AltClick"); + tClick(sx, sy); + TEST_STR_EQ(frtOut(&app), "alt\n"); + frtOutClear(&app); + basFormRtSetEvent(app.rt, cmd, "Click", "AltClick2"); + TEST_EQ(cmd->eventOverrideCount, 1); + tClick(sx, sy); + TEST_STR_EQ(frtOut(&app), "alt2\n"); + frtOutClear(&app); + basFormRtSetEvent(app.rt, cmd, "Click", "Missing"); + tClick(sx, sy); + TEST_STR_EQ(frtOut(&app), ""); + + // Dynamically created control wired from BASIC. + frtOutClear(&app); + tWidgetCenter(cmd2->widget, &sx, &sy); + tClick(sx, sy); + TEST_STR_EQ(frtOut(&app), "dyn Made\n"); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseSqlWrappers(void) { + tCase("SQL* wrappers through fake callbacks and without any"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = "Begin Form Form1\n Caption = \"Sql\"\nEnd\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + // No callbacks installed: every wrapper takes its guard path. + TEST_EQ(SQLOpen("db"), 0); + TEST_EQ(SQLExec(1, "x"), 0); + TEST_STR_EQ(SQLError(1), ""); + TEST_EQ(SQLQuery(1, "q"), 0); + TEST_EQ(SQLNext(1), 0); + TEST_EQ(SQLEof(1), -1); + TEST_EQ(SQLFieldCount(1), 0); + TEST_STR_EQ(SQLFieldName(1, 0), ""); + TEST_STR_EQ(SQLFieldText(1, 0), ""); + TEST_STR_EQ(SQLField(1, "n"), ""); + TEST_EQ(SQLFieldInt(1, 0), 0); + TEST_TRUE(SQLFieldDbl(1, 0) == 0.0); + TEST_EQ(SQLAffected(1), 0); + SQLFreeResult(1); + SQLClose(1); + + BasSqlCallbacksT tbl; + + memset(&tbl, 0, sizeof(tbl)); + tbl.sqlOpen = sqlFakeOpen; + tbl.sqlClose = sqlFakeClose; + tbl.sqlExec = sqlFakeExec; + tbl.sqlError = sqlFakeError; + tbl.sqlQuery = sqlFakeQuery; + tbl.sqlNext = sqlFakeNext; + tbl.sqlEof = sqlFakeEof; + tbl.sqlFieldCount = sqlFakeFieldCount; + tbl.sqlFieldName = sqlFakeFieldName; + tbl.sqlFieldText = sqlFakeFieldText; + tbl.sqlFieldByName = sqlFakeFieldByName; + tbl.sqlFieldInt = sqlFakeFieldInt; + tbl.sqlFieldDbl = sqlFakeFieldDbl; + tbl.sqlFreeResult = sqlFakeFree; + tbl.sqlAffectedRows = sqlFakeAffected; + basVmSetSqlCallbacks(app.vm, &tbl); + + TEST_EQ(SQLOpen("db"), SQL_FAKE_DB); + TEST_EQ(SQLOpen(NULL), 0); + TEST_EQ(SQLExec(SQL_FAKE_DB, "create"), -1); + TEST_EQ(SQLExec(SQL_FAKE_DB, NULL), 0); + TEST_STR_EQ(SQLError(SQL_FAKE_DB), "fake error"); + + int32_t rs = SQLQuery(SQL_FAKE_DB, "select"); + + TEST_EQ(rs, SQL_FAKE_RS); + TEST_EQ(SQLEof(rs), 0); + TEST_EQ(SQLNext(rs), -1); + TEST_EQ(SQLNext(rs), -1); + TEST_EQ(SQLFieldCount(rs), 2); + TEST_STR_EQ(SQLFieldName(rs, 0), "id"); + TEST_STR_EQ(SQLFieldName(rs, 1), "name"); + TEST_STR_EQ(SQLFieldText(rs, 1), "r1c1"); + TEST_STR_EQ(SQLField(rs, "name"), "r1c1"); + TEST_STR_EQ(SQLField(rs, "zip"), ""); + TEST_EQ(SQLFieldInt(rs, 1), 11); + TEST_TRUE(SQLFieldDbl(rs, 0) == 10.5); + TEST_EQ(SQLNext(rs), 0); + TEST_EQ(SQLEof(rs), -1); + SQLFreeResult(rs); + TEST_EQ(SQLAffected(SQL_FAKE_DB), 3); + SQLClose(SQL_FAKE_DB); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseTimers(void) { + tCase("Timer events on the fake clock"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Tmr\"\n" + " Begin Timer Tmr1\n" + " Interval = 100\n" + " Enabled = True\n" + " End\n" + "End\n" + "\n" + "Dim ticks As Integer\n" + "Sub Tmr1_Timer\n" + " ticks = ticks + 1\n" + " Print \"tick\"; ticks\n" + " If ticks = 3 Then Tmr1.Enabled = False\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *tmr = frtCtrl(form, "Tmr1"); + + TEST_TRUE(wgtTimerIsRunning(tmr->widget)); + tPump(2); + TEST_STR_EQ(frtOut(&app), ""); + + for (int32_t i = 0; i < TIMER_TICKS + 2; i++) { + tAdvanceClock(TIMER_INTERVAL_MS); + } + + TEST_STR_EQ(frtOut(&app), "tick1 \ntick2 \ntick3 \n"); + TEST_TRUE(!wgtTimerIsRunning(tmr->widget)); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseToolbarClearFromHandler(void) { + tCase("Toolbar.Clear from a toolbar button's own Click"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Tb\"\n" + " Begin Toolbar Tb1\n" + " End\n" + "End\n" + "\n" + "Sub Form1_Load\n" + " Tb1.AddTextButton \"TbA\", \"A\"\n" + " Tb1.AddSeparator\n" + " Tb1.AddTextButton \"TbB\", \"B\"\n" + "End Sub\n" + "Sub TbA_Click\n" + " Tb1.Clear\n" + " Print \"cleared \"; Tb1.ButtonCount()\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *tb = frtCtrl(form, "Tb1"); + BasControlT *tba = frtCtrl(form, "TbA"); + int32_t sx = 0; + int32_t sy = 0; + + TEST_TRUE(tb && tba); + TEST_EQ((int32_t)arrlen(form->controls), 4); + + if (tb && tba) { + tWidgetCenter(tba->widget, &sx, &sy); + tClick(sx, sy); + tPump(2); + TEST_STR_EQ(frtOut(&app), "cleared 0 \n"); + TEST_PTR_EQ(tb->widget->firstChild, NULL); + TEST_EQ((int32_t)arrlen(form->controls), 1); + TEST_EQ((int32_t)arrlen(app.rt->pendingCtrlFree), 0); + TEST_EQ(app.rt->eventDepth, 0); + } + + frtDestroy(&app); + tShutdown(); +} + + +static void caseUnloadDeferred(void) { + tCase("Unload Me inside a handler is deferred until it returns"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Def\"\n" + " Begin CommandButton Cmd1\n" + " Caption = \"Bye\"\n" + " End\n" + "End\n" + "\n" + "Dim n As Integer\n" + "Sub Cmd1_Click\n" + " n = 7\n" + " Unload Form1\n" + " Print \"after \"; n\n" + " Unload Form1\n" + "End Sub\n" + "Sub Form1_Unload\n" + " Print \"unload\"\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *cmd = frtCtrl(form, "Cmd1"); + int32_t sx = 0; + int32_t sy = 0; + + tWidgetCenter(cmd->widget, &sx, &sy); + tMouseMove(sx, sy); + tMouseDown(sx, sy, MOUSE_LEFT); + tMouseUp(sx, sy, MOUSE_LEFT); + TEST_STR_EQ(frtOut(&app), "unload\nafter 7 \n"); + tPump(2); + TEST_PTR_EQ(frtForm(&app, "Form1"), NULL); + TEST_EQ((int32_t)arrlen(app.rt->pendingUnload), 0); + TEST_EQ((int32_t)arrlen(app.rt->forms), 0); + TEST_PTR_EQ(tFindWindow("Def"), NULL); + TEST_EQ(app.rt->eventDepth, 0); + + frtDestroy(&app); + tShutdown(); +} + + +static void caseValidateCancel(void) { + tCase("Validate cancel reaches the widget as a refused write"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + const char *frm = + "Begin Form Form1\n" + " Caption = \"Val\"\n" + " Begin TextBox Txt1\n" + " End\n" + " Begin TextBox Txt\n" + " Index = 3\n" + " End\n" + "End\n" + "\n" + "Sub Txt1_Validate(Cancel As Integer)\n" + " If Txt1.Text = \"\" Then Cancel = 1\n" + " Print \"v1=\"; Cancel\n" + "End Sub\n" + "Sub Txt_Validate(Index As Integer, Cancel As Integer)\n" + " Cancel = Index\n" + " Print \"va=\"; Index; Cancel\n" + "End Sub\n"; + FrtAppT app; + + if (!expectBuild(&app, "", &frm, 1)) { + tShutdown(); + return; + } + + frtStart(&app, NULL); + + BasFormT *form = frtForm(&app, "Form1"); + BasControlT *txt = frtCtrl(form, "Txt1"); + BasControlT *arr = frtCtrlIdx(form, "Txt", 3); + + TEST_TRUE(txt && arr); + + if (txt && arr) { + TEST_TRUE(!txt->widget->onValidate(txt->widget)); + wgtSetText(txt->widget, "ok"); + TEST_TRUE(txt->widget->onValidate(txt->widget)); + TEST_TRUE(!arr->widget->onValidate(arr->widget)); + TEST_STR_EQ(frtOut(&app), "v1=1 \nv1=0 \nva=3 3 \n"); + } + + frtDestroy(&app); + tShutdown(); +} + + +// Call a control method whose arguments are all integers. + + +// idx-th direct child widget of w, or NULL. +static WidgetT *childAt(WidgetT *w, int32_t idx) { + WidgetT *c = w ? w->firstChild : NULL; + + while (c && idx > 0) { + c = c->nextSibling; + idx--; + } + + return c; +} + + +static void dialogClick(AppContextT *ctx, WindowT *modal, void *userCtx) { + DialogCtxT *dlg = (DialogCtxT *)userCtx; + + (void)ctx; + dlg->seen++; + snprintf(dlg->title, sizeof(dlg->title), "%s", modal->title); + + if (!tDialogClickButton(modal, dlg->button)) { + tDialogClickButton(modal, "OK"); + } +} + + +static bool expectBuild(FrtAppT *app, const char *bas, const char *const *frms, int32_t frmCount) { + bool ok = frtBuild(app, bas, frms, frmCount); + + if (!ok) { + printf(" compile error: %s\n", app->error); + } + + TEST_TRUE(ok); + return ok; +} + + +// Modal driver: type the configured text and accept with OK, or cancel +// the dialog with Esc when no text is configured. +static void fileDialogType(AppContextT *ctx, WindowT *modal, void *userCtx) { + FileDlgCtxT *fd = (FileDlgCtxT *)userCtx; + + (void)ctx; + + if (fd->busy) { + return; + } + + fd->busy = true; + fd->seen++; + + if (fd->typeText) { + // The save dialog's name input is the second child of the button + // row above the buttons (after path input, list, filter). + // The save/open dialogs keep their name input as the second + // child of the row above the buttons; plain dialogs have none. + WidgetT *nameInput = childAt(childAt(modal->widgetRoot, 3), 1); + + if (nameInput) { + int32_t sx = 0; + int32_t sy = 0; + + tWidgetCenter(nameInput, &sx, &sy); + tClick(sx, sy); + } + + tType(fd->typeText); + + if (!tDialogClickButton(modal, "OK") && !tDialogClickButton(modal, "Save") && !tDialogClickButton(modal, "Open")) { + tKeyEsc(); + } + } else { + tKeyEsc(); + } + + fd->busy = false; +} + + +double frtExtDbl(double d, float s) { + return d + (double)s; +} + + +int32_t frtExtFlag(int32_t a) { + return a != 0 ? -1 : 0; +} + + +int32_t frtExtInt(int32_t a) { + return a + 1; +} + + +int32_t frtExtLong(int32_t a, int32_t b) { + return a + b; +} + + +const char *frtExtStr(const char *s) { + return s; +} + + +void frtExtSub(int32_t a) { + (void)a; +} + + +static void outContains(const FrtAppT *app, const char *needle) { + bool found = strstr(frtOut(app), needle) != NULL; + + if (!found) { + printf(" PRINT output lacks [%s]; got:\n%s", needle, frtOut(app)); + } + + TEST_TRUE(found); +} + + +// Value written to the round-trip form for one interface property. +// Defaults by type; a few properties need a value the widget will keep +// with no items, rows or image data behind it. +static void parseOnCtrlBegin(void *userData, const char *typeName, const char *name) { + ParseRecT *rec = (ParseRecT *)userData; + + (void)name; + rec->ctrls++; + snprintf(rec->lastType, sizeof(rec->lastType), "%s", typeName); +} + + +static void parseOnCtrlEnd(void *userData) { + ParseRecT *rec = (ParseRecT *)userData; + + rec->ctrlEnds++; +} + + +static void parseOnCtrlProp(void *userData, const char *key, const char *value) { + ParseRecT *rec = (ParseRecT *)userData; + + rec->ctrlProps++; + snprintf(rec->lastKey, sizeof(rec->lastKey), "%s", key); + snprintf(rec->lastVal, sizeof(rec->lastVal), "%s", value); +} + + +static bool parseOnFormBegin(void *userData, const char *name) { + ParseRecT *rec = (ParseRecT *)userData; + + (void)name; + + if (rec->failFormBegin) { + return false; + } + + rec->forms++; + return true; +} + + +static void parseOnFormEnd(void *userData, const char *rest, int32_t restLen) { + ParseRecT *rec = (ParseRecT *)userData; + int32_t n = restLen; + + if (n > (int32_t)sizeof(rec->tail) - 1) { + n = (int32_t)sizeof(rec->tail) - 1; + } + + memcpy(rec->tail, rest, n); + rec->tail[n] = '\0'; +} + + +static void parseOnFormProp(void *userData, const char *key, const char *value) { + ParseRecT *rec = (ParseRecT *)userData; + + (void)key; + (void)value; + rec->formProps++; +} + + +static void parseOnMenuBegin(void *userData, const char *name, int32_t level) { + ParseRecT *rec = (ParseRecT *)userData; + + (void)name; + rec->menus++; + rec->lastMenuLevel = level; +} + + +static void parseOnMenuEnd(void *userData) { + ParseRecT *rec = (ParseRecT *)userData; + + rec->menuEnds++; +} + + +static void parseOnMenuProp(void *userData, const char *key, const char *value) { + ParseRecT *rec = (ParseRecT *)userData; + + (void)key; + (void)value; + rec->menuProps++; +} + + +static const char *roundTripValue(const char *basName, const WgtPropDescT *p, char *buf, int32_t bufSize) { + (void)basName; + + if (strcasecmp(p->name, "ListIndex") == 0 || strcasecmp(p->name, "SelectedRow") == 0) { + return "-1"; + } + + // A single page: index 0 is the only active one. + if (strcasecmp(p->name, "TabIndex") == 0 || strcasecmp(p->name, "Decimals") == 0) { + return "0"; + } + + // Below the minimum pane width the splitter clamps. + if (strcasecmp(p->name, "Position") == 0) { + return "100"; + } + + // The spinner keeps separate integer and real values, so switching + // RealMode after Value would read back the other one. + if (strcasecmp(p->name, "RealMode") == 0) { + return "False"; + } + + if (strcasecmp(p->name, "Picture") == 0) { + snprintf(buf, bufSize, "%s/icon.bmp", FRT_FIXTURE_DIR); + return buf; + } + + if (strcasecmp(p->name, "Enabled") == 0) { + return "False"; + } + + switch (p->type) { + case WGT_IFACE_STRING: + return "Sample"; + + case WGT_IFACE_INT: + return "3"; + + case WGT_IFACE_BOOL: + return "True"; + + case WGT_IFACE_ENUM: { + int32_t last = 0; + + while (p->enumNames && p->enumNames[last + 1]) { + last++; + } + + return p->enumNames ? p->enumNames[last] : "0"; + } + + default: + return "1"; + } +} + + +// Set a string property from a C literal, releasing the temporary. +static void setStrProp(FrtAppT *app, BasControlT *ctrl, const char *propName, const char *text) { + BasValueT v = basValStringFromC(text); + + basFormRtSetProp(app->rt, ctrl, propName, v); + basValRelease(&v); +} + + +static int32_t sSqlRow = -1; + + +static int32_t sqlFakeAffected(int32_t db) { + (void)db; + return 3; +} + + +static void sqlFakeClose(int32_t db) { + (void)db; +} + + +static bool sqlFakeEof(int32_t rs) { + (void)rs; + return sSqlRow >= SQL_FAKE_ROWS; +} + + +static const char *sqlFakeError(int32_t db) { + (void)db; + return "fake error"; +} + + +static bool sqlFakeExec(int32_t db, const char *sql) { + (void)db; + return sql != NULL; +} + + +static const char *sqlFakeFieldByName(int32_t rs, const char *name) { + if (strcmp(name, "id") == 0) { + return sqlFakeFieldText(rs, 0); + } + + if (strcmp(name, "name") == 0) { + return sqlFakeFieldText(rs, 1); + } + + return NULL; +} + + +static int32_t sqlFakeFieldCount(int32_t rs) { + (void)rs; + return 2; +} + + +static double sqlFakeFieldDbl(int32_t rs, int32_t col) { + return (double)sqlFakeFieldInt(rs, col) + 0.5; +} + + +static int32_t sqlFakeFieldInt(int32_t rs, int32_t col) { + (void)rs; + return sSqlRow * 10 + col; +} + + +static const char *sqlFakeFieldName(int32_t rs, int32_t col) { + (void)rs; + return col == 0 ? "id" : "name"; +} + + +static const char *sqlFakeFieldText(int32_t rs, int32_t col) { + static char buf[16]; + + (void)rs; + snprintf(buf, sizeof(buf), "r%dc%d", (int32_t)sSqlRow, (int32_t)col); + return buf; +} + + +static void sqlFakeFree(int32_t rs) { + (void)rs; +} + + +static bool sqlFakeNext(int32_t rs) { + (void)rs; + sSqlRow++; + return sSqlRow < SQL_FAKE_ROWS; +} + + +static int32_t sqlFakeOpen(const char *path) { + return path ? SQL_FAKE_DB : 0; +} + + +static int32_t sqlFakeQuery(int32_t db, const char *sql) { + (void)db; + (void)sql; + sSqlRow = -1; + return SQL_FAKE_RS; +} + + +static void stubsClose(void) { + sStub = NULL; + + if (sStubHandle) { + dlclose(sStubHandle); + sStubHandle = NULL; + } +} + + +static const FrtStubApiT *stubsOpen(void) { + sStubHandle = dlopen(FRT_STUBS_SO, RTLD_NOW | RTLD_GLOBAL); + + if (!sStubHandle) { + printf(" dlopen(%s): %s\n", FRT_STUBS_SO, dlerror()); + return NULL; + } + + const FrtStubApiT *(*getApi)(void) = (const FrtStubApiT *(*)(void))dlsym(sStubHandle, "frtStubApi"); + + if (!getApi) { + dlclose(sStubHandle); + sStubHandle = NULL; + return NULL; + } + + sStub = getApi(); + sStub->reset(); + return sStub; +} + + +int main(void) { + setvbuf(stdout, NULL, _IOLBF, 0); + tSuiteBegin("formrt"); + tProcessInit(DVXTEST_WIDGET_DIR); + + caseRoundTrip(); + caseMenus(); + caseEvents(); + caseToolbarClearFromHandler(); + caseRemoveContainerFromHandler(); + caseLoadForm2(); + caseSetEvent(); + caseQueryUnloadCancel(); + caseValidateCancel(); + casePopupMenu(); + casePropTables(); + caseCommonMethods(); + caseFormShowHide(); + caseUnloadDeferred(); + caseFormVars(); + caseControlArrays(); + caseMsgBox(); + caseTimers(); + caseSerialUnresolved(); + caseRuntimeError(); + caseMethodSignatures(); + casePropEdges(); + caseCreateFormDynamic(); + caseDataBinding(); + caseDialogWrappers(); + caseEventLoopExits(); + caseExternCalls(); + caseFrmLoadEdges(); + caseFrmParserUnit(); + caseHelpHooks(); + casePendingUnloadDrain(); + caseResources(); + caseRunSimple(); + caseSerialLoopback(); + caseSqlWrappers(); + caseResetCycles(); + + return tSuiteEnd(); +} diff --git a/src/test/host/Makefile b/src/test/host/Makefile new file mode 100644 index 0000000..2a47e8a --- /dev/null +++ b/src/test/host/Makefile @@ -0,0 +1,71 @@ +# 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. + +# Host-side smoke test for the native platform backend. +# +# Builds obj/host/libdvx.a, obj/host/libtasks.a and every widget .so with +# the host toolchain, links smoke.c against them with -rdynamic so the +# dlopen'd widgets resolve core symbols from the executable, then runs it. +# Sanitizers are on by default (SAN=1); pass SAN=0 for a plain build. + +SAN ?= 1 +HOSTCC = gcc +ROOT = ../../.. +OBJDIR = $(ROOT)/obj/host +BINDIR = $(OBJDIR)/test +LIBDVX = $(ROOT)/src/libs/kpunch/libdvx +LIBTASKS = $(ROOT)/src/libs/kpunch/libtasks +WIDGETS = $(ROOT)/src/widgets/kpunch +WIDGETDIR = $(OBJDIR)/widgets + +CFLAGS = -O1 -g -Wall -Wextra -Werror -Wno-type-limits -Wno-sign-compare -Wno-format-truncation -D_GNU_SOURCE -I$(LIBDVX) -I$(LIBDVX)/platform -I$(LIBDVX)/thirdparty -I$(LIBTASKS) -I$(WIDGETS) +LDFLAGS = -rdynamic +LDLIBS = -ldl -lm +ifeq ($(SAN),1) +CFLAGS += -fsanitize=address,undefined -fno-omit-frame-pointer +LDFLAGS += -fsanitize=address,undefined +endif + +TARGET = $(BINDIR)/smoke +PNG = $(BINDIR)/smoke.png + +.PHONY: all run libs clean + +all: run + +libs: + $(MAKE) -C $(LIBDVX) host SAN=$(SAN) + $(MAKE) -C $(LIBTASKS) host SAN=$(SAN) + $(MAKE) -C $(WIDGETS) host SAN=$(SAN) + +$(TARGET): smoke.c libs + @mkdir -p $(BINDIR) + $(HOSTCC) $(CFLAGS) $(LDFLAGS) -o $@ smoke.c -Wl,--whole-archive $(OBJDIR)/libdvx.a $(OBJDIR)/libtasks.a -Wl,--no-whole-archive $(LDLIBS) + +run: $(TARGET) + cd $(BINDIR) && ASAN_OPTIONS=detect_leaks=1 ./smoke $(abspath $(WIDGETDIR)) $(abspath $(PNG)) + +clean: + rm -rf $(BINDIR) + $(MAKE) -C $(LIBDVX) host-clean + $(MAKE) -C $(LIBTASKS) host-clean + $(MAKE) -C $(WIDGETS) host-clean diff --git a/src/test/host/smoke.c b/src/test/host/smoke.c new file mode 100644 index 0000000..4bd013b --- /dev/null +++ b/src/test/host/smoke.c @@ -0,0 +1,268 @@ +// 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. + +// smoke.c -- host platform backend smoke test +// +// Proves the native backend can carry the whole GUI stack end to end: +// load every widget shared object, bring up dvxInit on the offscreen +// framebuffer, build a window with a button, deliver a synthetic mouse +// click through the platform queue and the real event loop, confirm the +// onClick callback fired, and write the flushed framebuffer as a PNG. +// +// A second dvxShutdown/dvxInit cycle then runs in a forked child purely +// to report whether re-initialisation in one process survives; dangling +// widget statics are expected to make it crash until the test runner +// gains wgtTestReset() (Phase 2), so its outcome is reported, not +// asserted. +// +// Usage: smoke + +#include "dvxApp.h" +#include "dvxWgt.h" +#include "dvxPlatHost.h" +#include "box/box.h" +#include "button/button.h" +#include "stb_ds_wrap.h" + +#include +#include +#include +#include +#include +#include +#include + +// Offscreen display used for the run. +#define SMOKE_SCREEN_W 640 +#define SMOKE_SCREEN_H 480 +#define SMOKE_SCREEN_BPP 32 + +// Test window geometry. +#define SMOKE_WIN_X 100 +#define SMOKE_WIN_Y 80 +#define SMOKE_WIN_W 240 +#define SMOKE_WIN_H 160 + +// Frames pumped after building the window so layout and paint settle. +#define SMOKE_SETTLE_FRAMES 3 + +// Suffix of a loadable widget module. +#define WIDGET_SO_EXT ".so" + +#define CHECK(cond) do { if (!(cond)) { fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); exit(1); } } while (0) + + +typedef void (*WgtRegisterFnT)(void); + + +// ============================================================ +// Prototypes +// ============================================================ + +static int32_t loadWidgets(const char *dir); +static void onButtonClick(WidgetT *w); +static void pumpFrames(AppContextT *ctx, int32_t n); +static void runScenario(AppContextT *ctx, const char *pngPath); +static void secondInitProbe(AppContextT *ctx); + + +// ============================================================ +// Module state +// ============================================================ + +static int32_t sClickCount = 0; + + +// dlopen every *.so in dir RTLD_GLOBAL and call its wgtRegister once. +// Returns the number of modules registered. +static int32_t loadWidgets(const char *dir) { + char **names = dvxReadDir(dir); + int32_t count = 0; + + CHECK(names != NULL); + + int32_t n = (int32_t)arrlen(names); + + for (int32_t i = 0; i < n; i++) { + if (!dvxHasExt(names[i], WIDGET_SO_EXT)) { + continue; + } + + char path[DVX_MAX_PATH]; + + snprintf(path, sizeof(path), "%s" DVX_PATH_SEP "%s", dir, names[i]); + + void *handle = dlopen(path, RTLD_LAZY | RTLD_GLOBAL); + + if (!handle) { + fprintf(stderr, "dlopen %s: %s\n", path, dlerror()); + exit(1); + } + + WgtRegisterFnT reg = (WgtRegisterFnT)dlsym(handle, DVX_SYM("wgtRegister")); + + CHECK(reg != NULL); + reg(); + count++; + } + + dvxReadDirFree(names); + return count; +} + + +static void onButtonClick(WidgetT *w) { + (void)w; + sClickCount++; +} + + +static void pumpFrames(AppContextT *ctx, int32_t n) { + for (int32_t i = 0; i < n; i++) { + CHECK(dvxUpdate(ctx)); + } +} + + +static void runScenario(AppContextT *ctx, const char *pngPath) { + CHECK(dvxInit(ctx, SMOKE_SCREEN_W, SMOKE_SCREEN_H, SMOKE_SCREEN_BPP) == 0); + CHECK(ctx->display.width == SMOKE_SCREEN_W); + CHECK(ctx->display.format.bitsPerPixel == SMOKE_SCREEN_BPP); + + WindowT *win = dvxCreateWindow(ctx, "Smoke", SMOKE_WIN_X, SMOKE_WIN_Y, SMOKE_WIN_W, SMOKE_WIN_H, false); + CHECK(win != NULL); + + WidgetT *root = wgtInitWindow(ctx, win); + CHECK(root != NULL); + + WidgetT *box = wgtVBox(root); + CHECK(box != NULL); + + WidgetT *btn = wgtButton(box, "Click Me"); + CHECK(btn != NULL); + btn->onClick = onButtonClick; + + // Measure + arrange the finished tree (what every app does after + // building its widgets), then let the first paint flush. + wgtInvalidate(root); + pumpFrames(ctx, SMOKE_SETTLE_FRAMES); + CHECK(btn->w > 0 && btn->h > 0); + + int32_t cx = win->x + win->contentX + btn->x + btn->w / 2; + int32_t cy = win->y + win->contentY + btn->y + btn->h / 2; + + // Move, press, release: one queued sample per frame. + hostMousePush(cx, cy, 0); + pumpFrames(ctx, 1); + hostMousePush(cx, cy, MOUSE_LEFT); + pumpFrames(ctx, 1); + hostMousePush(cx, cy, 0); + pumpFrames(ctx, 1); + + CHECK(sClickCount == 1); + + // The flushed surface must contain the window (not all zero). + const uint8_t *fb = hostFramebuffer(&ctx->display); + bool nonZero = false; + + for (int32_t i = 0; i < ctx->display.pitch * ctx->display.height && !nonZero; i++) { + nonZero = fb[i] != 0; + } + + CHECK(nonZero); + + CHECK(hostSavePng(pngPath)); + + struct stat st; + + CHECK(stat(pngPath, &st) == 0 && st.st_size > 0); + + dvxShutdown(ctx); +} + + +// Re-run init/window/pump/shutdown in a child and report how it ended. +static void secondInitProbe(AppContextT *ctx) { + fflush(stdout); + fflush(stderr); + + pid_t pid = fork(); + + CHECK(pid >= 0); + + if (pid == 0) { + memset(ctx, 0, sizeof(*ctx)); + + if (dvxInit(ctx, SMOKE_SCREEN_W, SMOKE_SCREEN_H, SMOKE_SCREEN_BPP) != 0) { + _exit(2); + } + + WindowT *win = dvxCreateWindow(ctx, "Again", SMOKE_WIN_X, SMOKE_WIN_Y, SMOKE_WIN_W, SMOKE_WIN_H, false); + WidgetT *root = wgtInitWindow(ctx, win); + WidgetT *box = wgtVBox(root); + + wgtButton(box, "Again"); + pumpFrames(ctx, SMOKE_SETTLE_FRAMES); + dvxShutdown(ctx); + _exit(0); + } + + int status = 0; + + CHECK(waitpid(pid, &status, 0) == pid); + + if (WIFEXITED(status) && WEXITSTATUS(status) == 0) { + printf("second dvxInit in same process: OK\n"); + } else if (WIFSIGNALED(status)) { + printf("second dvxInit in same process: CRASHED (signal %d)\n", WTERMSIG(status)); + } else { + printf("second dvxInit in same process: FAILED (exit %d)\n", WEXITSTATUS(status)); + } +} + + +int main(int argc, char **argv) { + if (argc < 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 1; + } + + platformSetLogPath("smoke.log"); + + int32_t loaded = loadWidgets(argv[1]); + + printf("widgets loaded: %ld\n", (long)loaded); + CHECK(loaded > 0); + + AppContextT *ctx = (AppContextT *)calloc(1, sizeof(AppContextT)); + + CHECK(ctx != NULL); + + runScenario(ctx, argv[2]); + printf("click delivered: %ld, png: %s\n", (long)sClickCount, argv[2]); + + secondInitProbe(ctx); + + free(ctx); + printf("smoke: PASS\n"); + return 0; +} diff --git a/src/test/ide/Makefile b/src/test/ide/Makefile new file mode 100644 index 0000000..31687b6 --- /dev/null +++ b/src/test/ide/Makefile @@ -0,0 +1,136 @@ +# 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. + +# T6 -- DVX BASIC IDE under the dvxtest harness. +# +# Builds the IDE sources (ideMain.c is #included by testIde.c so the +# tests can inspect the IDE's file-scope state), the BASIC compiler, +# runtime, form runtime and basBuild natively, links them with the host +# libdvx and the dvxtest runner, and drives ideMain's appMain through +# menu commands, queued input and scripted dialogs. Shell functions the +# IDE calls directly are supplied by ideStubs.c. bin/host/bascomp is +# built too: it provides the STUB resource for Make Executable and the +# reference output the test byte-compares against. + +include ../dvxtest/dvxtest.mk + +.DEFAULT_GOAL := all + +BASIC := $(DVXTEST_ROOT)/src/apps/kpunch/dvxbasic +SHELL_DIR := $(DVXTEST_ROOT)/src/libs/kpunch/dvxshell +BINDIR := $(DVXTEST_BINDIR)/ide +# COV builds use the instrumented compiler/runner so their executions +# count toward stub/ coverage. +# BASCOMPREL/BASRUNREL are the literal (relative) target names the +# dvxbasic Makefile declares its rules with; absolute paths only make +# when the binaries already exist. +ifeq ($(COV),1) +BASCOMP := $(DVXTEST_ROOT)/obj/hostcov/basic/bascomp +BASRUN := $(DVXTEST_ROOT)/obj/hostcov/basic/basrun +BASCOMPREL := ../../../../obj/hostcov/basic/bascomp +BASRUNREL := ../../../../obj/hostcov/basic/basrun +else +BASCOMP := $(DVXTEST_ROOT)/bin/host/bascomp +BASRUN := $(DVXTEST_ROOT)/bin/host/basrun +BASCOMPREL := ../../../../bin/host/bascomp +BASRUNREL := ../../../../bin/host/basrun +endif +FIXTURES := $(abspath fixtures) +TARGET := $(BINDIR)/testIde +STUBTARGET := $(BINDIR)/testStub + +IDE_SRCS := ideDesigner.c ideProperties.c ideProject.c ideMenuEditor.c ideToolbox.c +COMP_SRCS := lexer.c parser.c codegen.c symtab.c strip.c obfuscate.c compact.c +RT_SRCS := vm.c values.c serialize.c +FRM_SRCS := formrt.c frmParser.c basNativeHost.c + +OBJS := $(BINDIR)/testIde.o $(BINDIR)/ideStubs.o +OBJS += $(patsubst %.c,$(BINDIR)/ide/%.o,$(IDE_SRCS)) +OBJS += $(patsubst %.c,$(BINDIR)/compiler/%.o,$(COMP_SRCS)) +OBJS += $(patsubst %.c,$(BINDIR)/runtime/%.o,$(RT_SRCS)) +OBJS += $(patsubst %.c,$(BINDIR)/formrt/%.o,$(FRM_SRCS)) +OBJS += $(BINDIR)/basBuild.o + +STUBOBJS := $(BINDIR)/testStub.o +STUBOBJS += $(patsubst %.c,$(BINDIR)/runtime/%.o,$(RT_SRCS)) +STUBOBJS += $(patsubst %.c,$(BINDIR)/formrt/%.o,$(FRM_SRCS)) + +CFLAGS := $(DVXTEST_CFLAGS) -Wno-stringop-truncation -I$(BASIC) -I$(BASIC)/ide -I$(SHELL_DIR) -DIDETEST_FIXTURE_DIR='"$(FIXTURES)"' -DIDETEST_BASCOMP='"$(BASCOMP)"' +BAS_HDRS := $(wildcard $(BASIC)/*.h $(BASIC)/ide/*.h $(BASIC)/compiler/*.h $(BASIC)/runtime/*.h $(BASIC)/formrt/*.h) + +.PHONY: all run cli libs bascomp clean + +all: run + +libs: dvxtest-libs + +bascomp: + $(MAKE) -C $(BASIC) $(BASCOMPREL) $(BASRUNREL) + +cli: bascomp + BASCOMP=$(BASCOMP) BASRUN=$(BASRUN) ./runCli.sh + +$(BINDIR)/testIde.o: testIde.c testIdeDesign.c testIdeDebug.c $(BASIC)/ide/ideMain.c $(BAS_HDRS) $(DVXTEST_HDRS) | libs + @mkdir -p $(BINDIR) + $(HOSTCC) $(CFLAGS) -c -o $@ $< + +$(BINDIR)/testStub.o: testStub.c $(BASIC)/stub/basstub.c $(BAS_HDRS) $(DVXTEST_HDRS) | libs + @mkdir -p $(BINDIR) + $(HOSTCC) $(CFLAGS) -c -o $@ $< + +$(BINDIR)/ideStubs.o: ideStubs.c $(BAS_HDRS) $(DVXTEST_HDRS) | libs + @mkdir -p $(BINDIR) + $(HOSTCC) $(CFLAGS) -c -o $@ $< + +$(BINDIR)/ide/%.o: $(BASIC)/ide/%.c $(BAS_HDRS) $(DVXTEST_HDRS) | libs + @mkdir -p $(dir $@) + $(HOSTCC) $(CFLAGS) -c -o $@ $< + +$(BINDIR)/compiler/%.o: $(BASIC)/compiler/%.c $(BAS_HDRS) | libs + @mkdir -p $(dir $@) + $(HOSTCC) $(CFLAGS) -c -o $@ $< + +$(BINDIR)/runtime/%.o: $(BASIC)/runtime/%.c $(BAS_HDRS) | libs + @mkdir -p $(dir $@) + $(HOSTCC) $(CFLAGS) -c -o $@ $< + +$(BINDIR)/formrt/%.o: $(BASIC)/formrt/%.c $(BAS_HDRS) $(DVXTEST_HDRS) | libs + @mkdir -p $(dir $@) + $(HOSTCC) $(CFLAGS) -c -o $@ $< + +$(BINDIR)/basBuild.o: $(BASIC)/basBuild.c $(BAS_HDRS) | libs + @mkdir -p $(BINDIR) + $(HOSTCC) $(CFLAGS) -c -o $@ $< + +$(TARGET): $(OBJS) $(DVXTEST_LIB) + $(HOSTCC) -o $@ $(OBJS) $(DVXTEST_LINK) + +$(STUBTARGET): $(STUBOBJS) $(DVXTEST_LIB) + $(HOSTCC) -o $@ $(STUBOBJS) $(DVXTEST_LINK) + +run: $(TARGET) $(STUBTARGET) bascomp + cd $(BINDIR) && ASAN_OPTIONS=detect_leaks=1 ./testIde + cd $(BINDIR) && ASAN_OPTIONS=detect_leaks=1 ./testStub + $(MAKE) cli + +clean: + rm -rf $(BINDIR) diff --git a/src/test/ide/fixtures/fixture.dbp b/src/test/ide/fixtures/fixture.dbp new file mode 100644 index 0000000..467f07a --- /dev/null +++ b/src/test/ide/fixtures/fixture.dbp @@ -0,0 +1,16 @@ +[Project] +Name = Fixture +Author = Test Author +Version = 1.0 + +[Modules] +File0 = main.bas +Count = 1 + +[Forms] +File0 = form1.frm +Count = 1 + +[Settings] +StartupForm = Form1 +OptionExplicit = False diff --git a/src/test/ide/fixtures/form1.frm b/src/test/ide/fixtures/form1.frm new file mode 100644 index 0000000..bda558e --- /dev/null +++ b/src/test/ide/fixtures/form1.frm @@ -0,0 +1,16 @@ +VERSION DVX 1.00 + +Begin Form Form1 + Caption = "Fixture Form" + AutoSize = False + Width = 300 + Height = 200 + Begin CommandButton btnGo + Caption = "Go" + End +End + +Sub btnGo_Click() + Call Helper + PRINT "clicked"; counter +End Sub diff --git a/src/test/ide/fixtures/main.bas b/src/test/ide/fixtures/main.bas new file mode 100644 index 0000000..ba4a5ed --- /dev/null +++ b/src/test/ide/fixtures/main.bas @@ -0,0 +1,7 @@ +' Fixture module +Dim Shared counter As Integer +counter = 10 + +Sub Helper() + counter = counter + 1 +End Sub diff --git a/src/test/ide/ideStubs.c b/src/test/ide/ideStubs.c new file mode 100644 index 0000000..43a659f --- /dev/null +++ b/src/test/ide/ideStubs.c @@ -0,0 +1,211 @@ +// 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. + +// ideStubs.c -- host stand-ins for the symbols the IDE links directly +// +// The IDE resolves most shell and library services lazily through dlsym +// (an unresolved lookup is tolerated), but three shell functions and the +// dvxSql API are referenced by name. The shell ones are reimplemented +// here exactly as dvxshell does them. The SQL ones implement a tiny +// scriptable fake (no sqlite): opening the magic IDESTUB_SQL_DB path +// succeeds and every query yields IDESTUB_SQL_COLS columns named colA / +// colB over IDESTUB_SQL_ROWS rows whose text is tblA / tblB, which is +// enough to drive the designer's data-binding editors; every other +// path fails cleanly. + +#include "dvxApp.h" +#include "dvxPlat.h" +#include "shellApp.h" +#include "../../libs/kpunch/sql/dvxSql.h" + +#include +#include +#include +#include + +// Database name the fake SQL API accepts (see header comment). +#define IDESTUB_SQL_DB "stub.db" + +#define STUB_SQL_NO_HANDLE (-1) +#define STUB_SQL_HANDLE 1 +#define STUB_SQL_RESULT 2 +#define STUB_SQL_COLS 2 +#define STUB_SQL_ROWS 2 +#define STUB_SQL_ERROR_TEXT "SQL is not available in the IDE test suite" + +int32_t ideStubShellLoadCount = 0; // shellLoadAppWithArgs calls seen +char ideStubShellLoadPath[DVX_MAX_PATH]; + +static int32_t sSqlRow = -1; // fake result-set cursor + +static const char *sSqlColNames[STUB_SQL_COLS] = { "colA", "colB" }; +static const char *sSqlRowText[STUB_SQL_ROWS] = { "tblA", "tblB" }; + +int32_t dvxSqlAffectedRows(int32_t db); +void dvxSqlClose(int32_t db); +bool dvxSqlEof(int32_t rs); +const char *dvxSqlError(int32_t db); +bool dvxSqlExec(int32_t db, const char *sql); +const char *dvxSqlFieldByName(int32_t rs, const char *name); +int32_t dvxSqlFieldCount(int32_t rs); +double dvxSqlFieldDbl(int32_t rs, int32_t col); +int32_t dvxSqlFieldInt(int32_t rs, int32_t col); +const char *dvxSqlFieldName(int32_t rs, int32_t col); +const char *dvxSqlFieldText(int32_t rs, int32_t col); +void dvxSqlFreeResult(int32_t rs); +bool dvxSqlNext(int32_t rs); +int32_t dvxSqlOpen(const char *path); +int32_t dvxSqlQuery(int32_t db, const char *sql); +void shellConfigPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize); +int32_t shellEnsureConfigDir(const DxeAppContextT *ctx); +int32_t shellLoadAppWithArgs(AppContextT *ctx, const char *path, const char *args); + + +int32_t dvxSqlAffectedRows(int32_t db) { + (void)db; + return 0; +} + + +void dvxSqlClose(int32_t db) { + (void)db; +} + + +bool dvxSqlEof(int32_t rs) { + (void)rs; + return true; +} + + +const char *dvxSqlError(int32_t db) { + (void)db; + return STUB_SQL_ERROR_TEXT; +} + + +bool dvxSqlExec(int32_t db, const char *sql) { + (void)db; + (void)sql; + return false; +} + + +const char *dvxSqlFieldByName(int32_t rs, const char *name) { + (void)rs; + (void)name; + return NULL; +} + + +int32_t dvxSqlFieldCount(int32_t rs) { + return rs == STUB_SQL_RESULT ? STUB_SQL_COLS : 0; +} + + +double dvxSqlFieldDbl(int32_t rs, int32_t col) { + (void)rs; + (void)col; + return 0.0; +} + + +int32_t dvxSqlFieldInt(int32_t rs, int32_t col) { + (void)rs; + (void)col; + return 0; +} + + +const char *dvxSqlFieldName(int32_t rs, int32_t col) { + if (rs != STUB_SQL_RESULT || col < 0 || col >= STUB_SQL_COLS) { + return NULL; + } + + return sSqlColNames[col]; +} + + +const char *dvxSqlFieldText(int32_t rs, int32_t col) { + if (rs != STUB_SQL_RESULT || col != 0 || sSqlRow < 0 || sSqlRow >= STUB_SQL_ROWS) { + return NULL; + } + + return sSqlRowText[sSqlRow]; +} + + +void dvxSqlFreeResult(int32_t rs) { + (void)rs; +} + + +bool dvxSqlNext(int32_t rs) { + if (rs != STUB_SQL_RESULT) { + return false; + } + + sSqlRow++; + return sSqlRow < STUB_SQL_ROWS; +} + + +int32_t dvxSqlOpen(const char *path) { + if (path && strstr(path, IDESTUB_SQL_DB)) { + return STUB_SQL_HANDLE; + } + + return STUB_SQL_NO_HANDLE; +} + + +int32_t dvxSqlQuery(int32_t db, const char *sql) { + (void)sql; + + if (db != STUB_SQL_HANDLE) { + return STUB_SQL_NO_HANDLE; + } + + sSqlRow = -1; + return STUB_SQL_RESULT; +} + + +void shellConfigPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize) { + snprintf(outPath, outSize, "%s" DVX_PATH_SEP "%s", ctx->configDir, filename); +} + + +int32_t shellEnsureConfigDir(const DxeAppContextT *ctx) { + return platformMkdirRecursive(ctx->configDir); +} + + +// The help viewer is never launched under test; record the request so a +// test can assert the IDE asked for it. +int32_t shellLoadAppWithArgs(AppContextT *ctx, const char *path, const char *args) { + (void)ctx; + (void)args; + ideStubShellLoadCount++; + snprintf(ideStubShellLoadPath, sizeof(ideStubShellLoadPath), "%s", path ? path : ""); + return 0; +} diff --git a/src/test/ide/runCli.sh b/src/test/ide/runCli.sh new file mode 100755 index 0000000..3faa945 --- /dev/null +++ b/src/test/ide/runCli.sh @@ -0,0 +1,227 @@ +#!/bin/sh +# 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. + +# runCli.sh -- every command-line option and error path of bascomp and +# basrun. Environment: BASCOMP / BASRUN name the binaries (default +# bin/host), CLI_OUT the scratch directory. + +set -u + +scriptDir="$(cd "$(dirname "$0")" && pwd)" +repoRoot="$(cd "$scriptDir/../../.." && pwd)" +bascomp="${BASCOMP:-$repoRoot/bin/host/bascomp}" +basrun="${BASRUN:-$repoRoot/bin/host/basrun}" +outDir="${CLI_OUT:-$repoRoot/obj/test/idecli}" +fixtureDir="$scriptDir/fixtures" + +passCount=0 +failCount=0 + +# expect -- run, compare the exit code. +expect() { + wantRc="$1" + name="$2" + shift 2 + "$@" > "$outDir/$name.out" 2> "$outDir/$name.err" + rc=$? + + if [ "$rc" = "$wantRc" ]; then + echo "PASS $name" + passCount=$((passCount + 1)) + else + echo "FAIL $name (exit $rc, expected $wantRc)" + sed 's/^/ /' "$outDir/$name.err" | head -3 + failCount=$((failCount + 1)) + fi +} + +# expectErr -- the last run's stderr must mention text. +expectErr() { + name="$1" + text="$2" + + if grep -q "$text" "$outDir/$name.err"; then + echo "PASS $name-msg" + passCount=$((passCount + 1)) + else + echo "FAIL $name-msg (stderr lacks '$text')" + sed 's/^/ /' "$outDir/$name.err" | head -3 + failCount=$((failCount + 1)) + fi +} + +rm -rf "$outDir" +mkdir -p "$outDir/prj" +cp "$fixtureDir/fixture.dbp" "$fixtureDir/main.bas" "$fixtureDir/form1.frm" "$outDir/prj/" + +# ---- bascomp ------------------------------------------------------------ + +expect 1 comp-noargs "$bascomp" +expect 1 comp-badopt "$bascomp" -frobnicate +expectErr comp-badopt "Unknown option" +expect 1 comp-noproj "$bascomp" -release +expectErr comp-noproj "no project file" +expect 1 comp-missing "$bascomp" "$outDir/missing.dbp" +expectErr comp-missing "cannot open project file" + +expect 0 comp-debug "$bascomp" "$outDir/prj/fixture.dbp" -o "$outDir/fix.app" +expect 0 comp-release "$bascomp" "$outDir/prj/fixture.dbp" -o "$outDir/fixr.app" -release + +# Default output name and a directory-less project path. +( cd "$outDir/prj" && "$bascomp" fixture.dbp ) > "$outDir/comp-default.out" 2> "$outDir/comp-default.err" + +if [ -f "$outDir/prj/Fixture.app" ]; then + echo "PASS comp-default" + passCount=$((passCount + 1)) +else + echo "FAIL comp-default (no Fixture.app)" + failCount=$((failCount + 1)) +fi + +# Compile error in a module. +mkdir -p "$outDir/badprj" +cp "$outDir/prj/fixture.dbp" "$outDir/badprj/" +cp "$outDir/prj/form1.frm" "$outDir/badprj/" +printf 'garbage@@@\n' > "$outDir/badprj/main.bas" +expect 1 comp-error "$bascomp" "$outDir/badprj/fixture.dbp" -o "$outDir/bad.app" +expectErr comp-error "Compile error" + +# Project referencing a missing module. +mkdir -p "$outDir/gone" +cp "$outDir/prj/fixture.dbp" "$outDir/gone/" +cp "$outDir/prj/form1.frm" "$outDir/gone/" +expect 1 comp-gone "$bascomp" "$outDir/gone/fixture.dbp" -o "$outDir/gone.app" + +# Unwritable output path. +expect 1 comp-unwritable "$bascomp" "$outDir/prj/fixture.dbp" -o /nonexistent/dir/x.app + +# ---- basrun ------------------------------------------------------------- + +expect 255 run-noargs "$basrun" +expect 255 run-badopt "$basrun" --frobnicate +expectErr run-badopt "unknown option" +expect 255 run-twoprogs "$basrun" a.bas b.bas +expectErr run-twoprogs "only one program" +expect 255 run-missingbas "$basrun" "$outDir/missing.bas" +expectErr run-missingbas "cannot read" +expect 255 run-missingapp "$basrun" "$outDir/missing.app" +expect 255 run-missingdbp "$basrun" "$outDir/missing.dbp" +expectErr run-missingdbp "cannot open project" + +# An .app without a resource directory / without a MODULE resource. +printf 'plain text' > "$outDir/plain.app" +expect 255 run-nores "$basrun" "$outDir/plain.app" +expectErr run-nores "no resource directory" + +# A crafted resource block whose MODULE is garbage. +python3 - "$outDir/corrupt.app" <<'PYEOF' +import struct, sys +path = sys.argv[1] +base = b"stub bytes" +data = b"garbage!" +entry = struct.pack("<32sIIII"[:len("<32sIIII")], b"MODULE", 3, len(base), len(data), 0) +footer = struct.pack(" "$outDir/bad.bas" +expect 255 run-badbas "$basrun" "$outDir/bad.bas" +expectErr run-badbas "compile error" + +# Runtime error: division by zero exits with the BASIC error number. +printf 'Dim z As Integer\nn%% = 1 \\ z\n' > "$outDir/div.bas" +expect 11 run-div "$basrun" "$outDir/div.bas" +expectErr run-div "runtime error 11" +expect 11 run-div-quiet "$basrun" --quiet "$outDir/div.bas" + +# Step cap on an endless loop. +printf 'Do\nLoop\n' > "$outDir/loop.bas" +expect 253 run-steps "$basrun" --steps 5000 "$outDir/loop.bas" +expectErr run-steps "step cap" + +# --data and --compact, plus a program path without a directory. +printf 'PRINT "hi"\n' > "$outDir/hi.bas" +mkdir -p "$outDir/data" +expect 0 run-data "$basrun" --data "$outDir/data" "$outDir/hi.bas" +expect 0 run-compact "$basrun" --compact "$outDir/hi.bas" +expect 0 run-app "$basrun" "$outDir/fix.app" +expect 0 run-app-compact "$basrun" --compact "$outDir/fix.app" +( cd "$outDir" && "$basrun" hi.bas ) > "$outDir/run-nodir.out" 2>&1 +rc=$? + +if [ "$rc" = "0" ]; then + echo "PASS run-nodir" + passCount=$((passCount + 1)) +else + echo "FAIL run-nodir (exit $rc)" + failCount=$((failCount + 1)) +fi + +# INPUT hitting EOF on stdin. +printf 'Dim n As Integer\nINPUT n\nPRINT n\n' > "$outDir/inp.bas" +"$basrun" "$outDir/inp.bas" < /dev/null > "$outDir/run-eof.out" 2> "$outDir/run-eof.err" +echo "PASS run-eof (exit $? tolerated)" +passCount=$((passCount + 1)) + +# INPUT answered from stdin. +printf '7\n' | "$basrun" "$outDir/inp.bas" > "$outDir/run-input.out" 2> "$outDir/run-input.err" +rc=$? + +if [ "$rc" = "0" ] && grep -q "7" "$outDir/run-input.out"; then + echo "PASS run-input" + passCount=$((passCount + 1)) +else + echo "FAIL run-input (exit $rc)" + failCount=$((failCount + 1)) +fi + +# DoEvents drives the host callback. +printf 'DoEvents\nPRINT "ok"\n' > "$outDir/doev.bas" +expect 0 run-doevents "$basrun" "$outDir/doev.bas" + +# A long but bounded program crosses several step slices uncapped. +printf 'Dim i As Long\nFor i = 1 To 200000\nNext i\nPRINT "end"\n' > "$outDir/longrun.bas" +expect 0 run-slices "$basrun" "$outDir/longrun.bas" + +# A full project compiles and runs (form opcodes are no-ops here). +expect 0 run-dbp "$basrun" "$outDir/prj/fixture.dbp" + +# An .app with resources but no MODULE (the compiler binary itself). +cp "$bascomp" "$outDir/nomod.app" +expect 255 run-nomod "$basrun" "$outDir/nomod.app" +expectErr run-nomod "MODULE resource not found" + +echo "" +echo "runCli: $passCount passed, $failCount failed" + +if [ "$failCount" -gt 0 ]; then + exit 1 +fi + +exit 0 diff --git a/src/test/ide/testIde.c b/src/test/ide/testIde.c new file mode 100644 index 0000000..d76cff7 --- /dev/null +++ b/src/test/ide/testIde.c @@ -0,0 +1,1742 @@ +// 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. + +// testIde.c -- T6: the DVX BASIC IDE driven under the dvxtest harness +// +// ideMain.c is #included so the tests can read the IDE's file-scope +// state (project, editor binding, breakpoints, debug state, VM) while +// driving it the way a user does: menu commands through the main +// window's onMenu, typed input through the platform queues, project +// tree double-clicks, and scripted modal dialogs. Every case copies +// the fixture project (fixtures/) into a fresh temp directory, runs +// appMain against a writable config dir, and exits the IDE through +// File / Exit before tearing the display down. +// +// Runs and debug sessions block inside runModule's nested event loop, +// so those cases install an idle-frame script (runIdle) that performs +// one step per idle frame from inside that loop. + +#include "dvxtest.h" + +// Progress marker: name every case on stdout so a stuck run shows +// exactly where it stopped. +#define tCase(name) (printf("CASE %s\n", (name)), (tCase)(name)) + +#include "ideMain.c" +#include "treeView/treeView.h" +#include "spinner/spinner.h" + +#include +#include +#include +#include +#include + +#define SCREEN_W 800 +#define SCREEN_H 600 +#define SCREEN_BPP 32 +#define SETTLE_FRAMES 3 +#define STOP_FRAME_LIMIT 4 // frames allowed between Stop and DBG_IDLE +#define RUN_FRAME_LIMIT 400 // frames a scripted run may take in total +#define PROJECT_DIR_NAME "prj" +#define CONFIG_DIR_NAME "config" +#define FIXTURE_DBP "fixture.dbp" +#define FIXTURE_BAS "main.bas" +#define FIXTURE_FRM "form1.frm" +#define FIXTURE_NAME "Fixture" +#define FIXTURE_FORM_TITLE "Fixture Form" +#define FIXTURE_DESIGN_TITLE "Fixture Form [Design]" +#define FIXTURE_BUTTON "Go" +#define FIXTURE_BUTTON_NAME "btnGo" +#define FIXTURE_RENAMED "btnRun" +#define FIXTURE_HANDLER "btnGo_Click" +#define FIXTURE_RENAMED_HANDLER "btnRun_Click" +#define FIXTURE_GLOBAL_LINE "counter = 10" +#define FIXTURE_HELPER_LINE "counter = counter + 1" +#define FIXTURE_CALL_LINE "Call Helper" +#define FIXTURE_HELPER_DECL "Sub Helper" +#define FIXTURE_OUTPUT_TEXT "clicked" +#define FIXTURE_OUTPUT_VALUE "11" +#define FIXTURE_PAUSED_VALUE "10" +#define FIXTURE_GLOBAL_NAME "counter" +#define FIXTURE_REPLACED_NAME "total" +#define FILE_MAIN 0 +#define FILE_FORM 1 +#define EDIT_MARK_BAS "' bas edit" +#define EDIT_MARK_FRM "' frm edit" +#define EDIT_MARK_DIRTY "' dirty" +#define EDIT_LINE_ONE "' one" +#define EDIT_LINE_TWO "' two" +#define INSERTED_LINES 2 +#define IDE_APP_NAME "ide.app" +#define REF_APP_NAME "ref.app" +#define BASCOMP_SNAPSHOT "bascomp.snapshot" +#define EXEC_MODE 0755 +#define PREFS_TAB_WIDTH_TEXT "5" +#define PREFS_TAB_WIDTH_VALUE 5 +#define PREFS_TAB_WIDTH_BIG "99" +#define PREFS_AUTHOR "Ada" +#define BUILD_MODE_RELEASE 1 +#define BTN_YES "Yes" +#define BTN_NO "No" +#define BTN_CANCEL "Cancel" +#define BTN_OK "OK" +#define BTN_OPEN "Open" +#define BTN_SAVE "Save" +#define TITLE_REMOVE_FILE "Remove File" +#define TITLE_NEW_PROJECT "New Project" +#define TITLE_EDIT_PROPERTY "Edit Property" +#define TITLE_OPEN_PROJECT "Open Project" +#define TITLE_ADD_FILE "Add File" +#define TITLE_MAKE_EXE "Make Executable" +#define TITLE_BUILD_MODE "Build Mode" +#define TITLE_ERROR "Error" +#define TITLE_PREFERENCES "Preferences" +#define TITLE_PROPERTIES "Properties" +#define TITLE_INVALID_ICON "Invalid Icon" +#define TITLE_PROJECT_WIN "Project" +#define PROP_ROW_NAME "Name" +#define MODULE_FRAME_NAME IDE_MODULE_FRAME +#define CMD_BUF (DVX_MAX_PATH * 3 + 64) +#define LINE_BUF 256 + +// Scripted answers for every modal the IDE can raise during a command. +typedef struct { + const char *promptButton; // save prompt: "Yes" / "No" / "Cancel" + const char *inputText; // input boxes: text to enter (NULL = Cancel) + const char *fileDir; // file dialogs: directory to navigate to + const char *fileName; // file dialogs: name to accept (NULL = Cancel) + const char *fileButton; // file dialogs: "Open" / "Save" + const char *fileTitle; // generic file dialogs: title to answer + const char *choiceTitle; // generic choice dialogs: title to answer + int32_t choiceIndex; // choice dialogs: item to pick + int32_t intValue; // integer input boxes: value to spin to + bool useSpinner; // Edit Property holds a spinner, not a text input + void (*custom)(WindowT *modal); // Preferences and other app windows + int32_t lastModalId; // one action per modal window + int32_t prompts; // save prompts answered + int32_t errors; // error boxes dismissed + char lastTitle[MAX_TITLE_LEN]; +} DialogPlanT; + +typedef void (*RunStepFnT)(void); + +static void caseBreakInHandler(void); +static void caseBreakpointTracking(void); +static void caseCloseDirtyPrompt(void); +static void caseFindReplace(void); +static void caseMakeExecutable(void); +static void caseOpenEditDesignSave(void); +static void caseOpenViaFileDialog(void); +static void casePreferences(void); +static void casePromptNewAndOpen(void); +static void casePromptRecent(void); +static void caseRecentPersistence(void); +static void caseRemoveFile(void); +static void caseRenameControl(void); +static void caseRunClickAndRefusals(void); +static void clickFixtureButton(void); +static void dialogAnswer(AppContextT *ctx, WindowT *modal, void *userCtx); +static void dialogPlanInit(DialogPlanT *plan, const char *promptButton); +static void editorFocus(void); +static void editorTypeAtEnd(const char *text); +static bool fileHas(const char *path, const char *needle); +static bool filesIdentical(const char *pathA, const char *pathB); +static WidgetT *findOfClass(WidgetT *root, const void *wclass, int32_t nth); +static WidgetT *findTreeItem(WidgetT *root, int32_t fileIdx); +static WindowT *findWindowPrefix(const char *prefix); +static void fixturePrepare(void); +static void ideExit(void); +static void ideMenu(int32_t cmd); +static void ideStart(bool primeRecent); +static int32_t lineOf(const char *text, const char *needle); +static void lineText(const char *text, int32_t line, char *out, int32_t outSize); +static int32_t listRowByName(WidgetT *list, const char *name); +static void openViaRecent(void); +static void prefsCheckClamped(WindowT *modal); +static void prefsFill(WindowT *modal); +static void prefsFillBig(WindowT *modal); +static void prefsVerify(WindowT *modal); +static void probeClasses(void); +static void renameViaProperties(const char *oldName, const char *newName); +static void runIdle(void *userCtx); +static void runScript(RunStepFnT fn, int32_t cmd); +static void snapshotBascomp(void); +static int32_t sourceMapStart(int32_t fileIdx); +static void stepBreakInHandler(void); +static void stepModuleBreakpoint(void); +static void stepRunClick(void); +static WidgetT *treeOfProjectWindow(void); +static void treeOpenFile(int32_t fileIdx); +static void treeSelectFile(int32_t fileIdx); +int main(void); + +static DxeAppContextT sDxe; +static char sProjectDir[DVX_MAX_PATH]; +static char sDbpPath[DVX_MAX_PATH]; +static char sBasPath[DVX_MAX_PATH]; +static char sFrmPath[DVX_MAX_PATH]; +static char sPrefsPath[DVX_MAX_PATH]; +static const void *sTextInputClass = NULL; +static const void *sListBoxClass = NULL; +static const void *sListViewClass = NULL; +static const void *sSpinnerClass = NULL; +static const void *sTreeViewClass = NULL; +static const void *sDropdownClass = NULL; +static const void *sCheckboxClass = NULL; +static const void *sTextAreaClass = NULL; +static RunStepFnT sRunFn = NULL; +static int32_t sRunStep = 0; +static int32_t sRunStartFrame = 0; +static int32_t sStopFrame = -1; +static int32_t sUnexpectedModals = 0; +static int32_t sHandlerLine = 0; // editor line of the handler breakpoint +static DialogPlanT sExitPlan; +static char sBascompPath[DVX_MAX_PATH]; +static const char *sRunInputText = NULL; // reply for an INPUT box during a scripted run + + +// A breakpoint inside the Click handler pauses the VM in the middle of +// the button's event dispatch; while paused the Immediate window, Watch, +// Locals and Call Stack must all see the handler's frame, and Stop must +// return the IDE to idle within a few frames. +static void caseBreakInHandler(void) { + tCase("breakpoint inside a Click handler: pause, immediate, watch, locals, call stack, stop"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + + treeOpenFile(FILE_FORM); + ideMenu(CMD_VIEW_CODE); + TEST_EQ(sEditorFileIdx, FILE_FORM); + TEST_EQ((int32_t)arrlen(sProcBufs), 1); + showProc(0); + + int32_t callLine = lineOf(wgtGetText(sEditor), FIXTURE_CALL_LINE); + + TEST_TRUE(callLine > 0); + toggleBreakpointLine(callLine); + TEST_EQ((int32_t)arrlen(sBreakpoints), 1); + sHandlerLine = callLine; + + runScript(stepBreakInHandler, CMD_DEBUG); + + TEST_EQ(sRunStep, 3); + TEST_EQ(sDbgState, DBG_IDLE); + TEST_PTR_EQ(sVm, NULL); + TEST_TRUE(sStopFrame >= 0); + TEST_TRUE(sAc->frameCount - sStopFrame <= STOP_FRAME_LIMIT); + TEST_EQ(sUnexpectedModals, 0); + tPump(SETTLE_FRAMES); + TEST_PTR_EQ(tFindWindow(FIXTURE_FORM_TITLE), NULL); + + ideExit(); + tShutdown(); +} + + +// Toggling a breakpoint, inserting lines above it, then toggling another +// in a different procedure must leave the VM breakpoint list pointing at +// the same statements in the concatenated compile source. The module- +// level breakpoint is then proven by actually pausing on it. +static void caseBreakpointTracking(void) { + tCase("breakpoint lines track edits and match the compiled source"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_MAIN); + + int32_t globalLine = lineOf(wgtGetText(sEditor), FIXTURE_GLOBAL_LINE); + + TEST_TRUE(globalLine > 0); + toggleBreakpointLine(globalLine); + TEST_EQ((int32_t)arrlen(sBreakpoints), 1); + TEST_EQ(sBreakpoints[0].codeLine, globalLine); + + // Insert two comment lines at the top of the (General) section + editorFocus(); + wgtTextAreaGoToLine(sEditor, 1); + tType(EDIT_LINE_ONE); + tKeyEnter(); + tType(EDIT_LINE_TWO); + tKeyEnter(); + TEST_EQ((int32_t)arrlen(sBreakpoints), 1); + TEST_EQ(sBreakpoints[0].codeLine, globalLine + INSERTED_LINES); + TEST_EQ(lineOf(wgtGetText(sEditor), FIXTURE_GLOBAL_LINE), globalLine + INSERTED_LINES); + + // Second breakpoint inside Helper + showProc(0); + + int32_t helperLine = lineOf(wgtGetText(sEditor), FIXTURE_HELPER_LINE); + + TEST_TRUE(helperLine > 0); + toggleBreakpointLine(helperLine); + TEST_EQ((int32_t)arrlen(sBreakpoints), 2); + TEST_EQ(sBreakpoints[1].codeLine, sProcTable[0].lineNum + helperLine - 1); + + // Compile and check every VM line against the packed source text + TEST_TRUE(compileProject()); + dvxSetBusy(sAc, false); + buildVmBreakpoints(); + TEST_EQ((int32_t)arrlen(sVmBreakpoints), 2); + + char *packed = loadCompileSource(FILE_MAIN); + + TEST_TRUE(packed != NULL); + + if (packed) { + const char *expected[] = { FIXTURE_GLOBAL_LINE, FIXTURE_HELPER_LINE }; + + for (int32_t i = 0; i < (int32_t)arrlen(sBreakpoints) && i < (int32_t)arrlen(sVmBreakpoints); i++) { + char line[LINE_BUF]; + + lineText(packed, sBreakpoints[i].codeLine, line, sizeof(line)); + TEST_TRUE(strstr(line, expected[i]) != NULL); + TEST_EQ(sVmBreakpoints[i], sourceMapStart(FILE_MAIN) + sBreakpoints[i].codeLine - 1); + } + + free(packed); + } + + // Debug run: the module-level statement pauses on exactly that line + runScript(stepModuleBreakpoint, CMD_DEBUG); + TEST_EQ(sRunStep, 1); + TEST_EQ(sDbgState, DBG_IDLE); + TEST_EQ(sUnexpectedModals, 0); + + ideExit(); + tShutdown(); +} + + +// Closing the IDE with a dirty module prompts; Cancel keeps it open, +// Yes saves and closes. +static void caseCloseDirtyPrompt(void) { + tCase("onClose with dirty state prompts (Cancel keeps, Yes saves)"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_MAIN); + editorTypeAtEnd(EDIT_MARK_DIRTY); + TEST_TRUE(sProject.files[FILE_MAIN].modified); + + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_CANCEL); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_EXIT); + TEST_EQ(plan.prompts, 1); + TEST_TRUE(sWin != NULL); + TEST_TRUE(hasProject()); + TEST_TRUE(!fileHas(sBasPath, EDIT_MARK_DIRTY)); + + dialogPlanInit(&plan, BTN_YES); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_EXIT); + tDialogScript(NULL, NULL); + TEST_EQ(plan.prompts, 1); + TEST_PTR_EQ(sWin, NULL); + TEST_TRUE(fileHas(sBasPath, EDIT_MARK_DIRTY)); + tPump(SETTLE_FRAMES); + tShutdown(); +} + + +// Find Next with project scope jumps to the file and procedure that +// holds the match; Replace All with project scope rewrites every buffer. +static void caseFindReplace(void) { + tCase("find/replace across the project"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_FORM); + ideMenu(CMD_VIEW_CODE); + TEST_EQ(sEditorFileIdx, FILE_FORM); + + ideMenu(CMD_REPLACE); + TEST_TRUE(sFindWin != NULL); + TEST_TRUE(sFindInput != NULL && sReplInput != NULL && sScopeGroup != NULL); + + // Find: "Sub Helper" lives only in main.bas + wgtSetText(sFindInput, FIXTURE_HELPER_DECL); + wgtRadioGroupSetSelected(sScopeGroup, ScopeProjE); + onFindNext(NULL); + tPump(1); + TEST_EQ(sEditorFileIdx, FILE_MAIN); + TEST_EQ(sCurProcIdx, 0); + TEST_TRUE(strstr(wgtGetText(sEditor), FIXTURE_HELPER_DECL) != NULL); + TEST_EQ(wgtTextAreaGetCursorLine(sEditor), lineOf(wgtGetText(sEditor), FIXTURE_HELPER_DECL)); + + // Replace All: counter -> total in both files + wgtSetText(sFindInput, FIXTURE_GLOBAL_NAME); + wgtSetText(sReplInput, FIXTURE_REPLACED_NAME); + wgtCheckboxSetChecked(sReplCheck, true); + onReplCheckChange(sReplCheck); + wgtRadioGroupSetSelected(sScopeGroup, ScopeProjE); + onReplaceAll(NULL); + tPump(1); + stashCurrentFile(); + + for (int32_t i = 0; i < sProject.fileCount; i++) { + const char *buf = sProject.files[i].buffer; + + TEST_TRUE(buf != NULL); + + if (buf) { + TEST_TRUE(strstr(buf, FIXTURE_REPLACED_NAME) != NULL); + TEST_PTR_EQ(strstr(buf, FIXTURE_GLOBAL_NAME), NULL); + } + + TEST_TRUE(sProject.files[i].modified); + } + + TEST_TRUE(compileProject()); + dvxSetBusy(sAc, false); + ideExit(); + tShutdown(); +} + + +// Make Executable must produce exactly what the command-line compiler +// produces for the same project in release mode. +static void caseMakeExecutable(void) { + tCase("Make Executable output is byte-identical to bascomp -release"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + + char idePath[DVX_MAX_PATH]; + char refPath[DVX_MAX_PATH]; + char cmd[CMD_BUF]; + + snprintf(idePath, sizeof(idePath), "%s" DVX_PATH_SEP IDE_APP_NAME, tTempDir()); + snprintf(refPath, sizeof(refPath), "%s" DVX_PATH_SEP REF_APP_NAME, tTempDir()); + + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_NO); + plan.fileDir = tTempDir(); + plan.fileName = IDE_APP_NAME; + plan.fileButton = BTN_SAVE; + plan.choiceIndex = BUILD_MODE_RELEASE; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_MAKE_EXE); + tDialogScript(NULL, NULL); + TEST_STR_EQ(plan.lastTitle, TITLE_BUILD_MODE); + TEST_EQ(plan.errors, 0); + + struct stat st; + + TEST_TRUE(stat(idePath, &st) == 0 && st.st_size > 0); + + snprintf(cmd, sizeof(cmd), "%s %s -o %s -release > /dev/null", sBascompPath, sDbpPath, refPath); + TEST_EQ(system(cmd), 0); + TEST_TRUE(filesIdentical(idePath, refPath)); + + ideExit(); + tShutdown(); +} + + +// Open a project, edit a module in the editor, switch to the form's +// design view and back, save: the edit lands on disk. +static void caseOpenEditDesignSave(void) { + tCase("open, edit, design view, back, save"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + TEST_EQ(sProject.fileCount, 2); + TEST_STR_EQ(sProject.name, FIXTURE_NAME); + TEST_TRUE(tFindWindow(TITLE_PROJECT_WIN) != NULL); + + treeOpenFile(FILE_MAIN); + TEST_EQ(sProject.activeFileIdx, FILE_MAIN); + TEST_EQ(sEditorFileIdx, FILE_MAIN); + TEST_TRUE(sCodeWin != NULL); + TEST_TRUE(strstr(wgtGetText(sEditor), FIXTURE_GLOBAL_LINE) != NULL); + + editorTypeAtEnd(EDIT_MARK_BAS); + TEST_TRUE(sProject.files[FILE_MAIN].modified); + TEST_TRUE(strstr(sWin->title, "*") != NULL); + + treeSelectFile(FILE_FORM); + ideMenu(CMD_VIEW_DESIGN); + TEST_EQ(sProject.activeFileIdx, FILE_FORM); + TEST_TRUE(findWindowPrefix(FIXTURE_DESIGN_TITLE) != NULL); + TEST_TRUE(sDesigner.form != NULL); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 1); + TEST_STR_EQ(sDesigner.form->controls[0]->name, FIXTURE_BUTTON_NAME); + + ideMenu(CMD_VIEW_CODE); + TEST_EQ(sEditorFileIdx, FILE_FORM); + TEST_EQ((int32_t)arrlen(sProcBufs), 1); + TEST_TRUE(strstr(sProcBufs[0], FIXTURE_HANDLER) != NULL); + + treeOpenFile(FILE_MAIN); + TEST_EQ(sEditorFileIdx, FILE_MAIN); + TEST_TRUE(strstr(wgtGetText(sEditor), EDIT_MARK_BAS) != NULL); + TEST_TRUE(!fileHas(sBasPath, EDIT_MARK_BAS)); + + ideMenu(CMD_SAVE); + TEST_TRUE(!sProject.files[FILE_MAIN].modified); + TEST_TRUE(fileHas(sBasPath, EDIT_MARK_BAS)); + TEST_TRUE(fileHas(sBasPath, FIXTURE_GLOBAL_LINE)); + TEST_PTR_EQ(strstr(sWin->title, "*"), NULL); + + ideExit(); + tShutdown(); +} + + +// File / Open Project through the file dialog: navigate to the project +// directory, type the name, Open. +static void caseOpenViaFileDialog(void) { + tCase("open project through the file dialog"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(false); + TEST_TRUE(!hasProject()); + + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_NO); + plan.fileDir = sProjectDir; + plan.fileName = FIXTURE_DBP; + plan.fileButton = BTN_OPEN; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_OPEN); + tDialogScript(NULL, NULL); + TEST_STR_EQ(plan.lastTitle, TITLE_OPEN_PROJECT); + TEST_EQ(plan.prompts, 0); + TEST_TRUE(hasProject()); + TEST_STR_EQ(sProject.name, FIXTURE_NAME); + TEST_EQ(sProject.fileCount, 2); + TEST_EQ((int32_t)arrlen(sRecentFiles), 1); + + ideExit(); + tShutdown(); +} + + +// Preferences: values entered in the dialog persist to the INI, come +// back when the dialog reopens, and out-of-range tab widths clamp. +static void casePreferences(void) { + tCase("preferences dialog round trip"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(false); + + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_NO); + plan.custom = prefsFill; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PREFERENCES); + TEST_STR_EQ(plan.lastTitle, TITLE_PREFERENCES); + TEST_EQ(prefsGetInt(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_TAB_WIDTH, 0), PREFS_TAB_WIDTH_VALUE); + TEST_TRUE(!prefsGetBool(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_USE_SPACES, true)); + TEST_STR_EQ(prefsGetString(sPrefs, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_AUTHOR, ""), PREFS_AUTHOR); + + PrefsHandleT *disk = prefsLoad(sPrefsPath); + + TEST_TRUE(disk != NULL); + + if (disk) { + TEST_EQ(prefsGetInt(disk, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_TAB_WIDTH, 0), PREFS_TAB_WIDTH_VALUE); + TEST_TRUE(!prefsGetBool(disk, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_USE_SPACES, true)); + TEST_STR_EQ(prefsGetString(disk, IDE_PREF_SECTION_DEFAULTS, IDE_PREF_KEY_AUTHOR, ""), PREFS_AUTHOR); + prefsClose(disk); + } + + dialogPlanInit(&plan, BTN_NO); + plan.custom = prefsVerify; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PREFERENCES); + TEST_EQ(prefsGetInt(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_TAB_WIDTH, 0), PREFS_TAB_WIDTH_VALUE); + + dialogPlanInit(&plan, BTN_NO); + plan.custom = prefsFillBig; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PREFERENCES); + TEST_EQ(prefsGetInt(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_TAB_WIDTH, 0), IDE_MAX_TAB_WIDTH); + + dialogPlanInit(&plan, BTN_NO); + plan.custom = prefsCheckClamped; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PREFERENCES); + tDialogScript(NULL, NULL); + + ideExit(); + tShutdown(); +} + + +// New Project and Open Project both prompt for a dirty module before +// anything else happens: Cancel aborts, Yes saves and continues into the +// next dialog. +static void casePromptNewAndOpen(void) { + tCase("New/Open Project with a dirty module prompts"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_MAIN); + editorTypeAtEnd(EDIT_MARK_DIRTY); + TEST_TRUE(sProject.files[FILE_MAIN].modified); + + DialogPlanT plan; + + // New Project, Cancel: nothing changes + dialogPlanInit(&plan, BTN_CANCEL); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_NEW); + TEST_EQ(plan.prompts, 1); + TEST_STR_EQ(plan.lastTitle, IDE_MAIN_TITLE); + TEST_TRUE(hasProject()); + TEST_TRUE(sProject.files[FILE_MAIN].modified); + TEST_TRUE(!fileHas(sBasPath, EDIT_MARK_DIRTY)); + + // New Project, Yes: saved, then the name box appears (cancelled here) + dialogPlanInit(&plan, BTN_YES); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_NEW); + TEST_EQ(plan.prompts, 1); + TEST_STR_EQ(plan.lastTitle, TITLE_NEW_PROJECT); + TEST_TRUE(hasProject()); + TEST_TRUE(!sProject.files[FILE_MAIN].modified); + TEST_TRUE(fileHas(sBasPath, EDIT_MARK_DIRTY)); + + // Open Project, Cancel: aborted before the file dialog + editorTypeAtEnd(EDIT_MARK_BAS); + TEST_TRUE(sProject.files[FILE_MAIN].modified); + dialogPlanInit(&plan, BTN_CANCEL); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_OPEN); + TEST_EQ(plan.prompts, 1); + TEST_STR_EQ(plan.lastTitle, IDE_MAIN_TITLE); + TEST_TRUE(sProject.files[FILE_MAIN].modified); + + // Open Project, No: the file dialog appears (cancelled here), nothing saved + dialogPlanInit(&plan, BTN_NO); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_OPEN); + tDialogScript(NULL, NULL); + TEST_EQ(plan.prompts, 1); + TEST_STR_EQ(plan.lastTitle, TITLE_OPEN_PROJECT); + TEST_TRUE(hasProject()); + TEST_TRUE(!fileHas(sBasPath, EDIT_MARK_BAS)); + + ideExit(); + tShutdown(); +} + + +// Opening a recent project with a dirty module prompts: Yes saves all, +// No discards (the reopened buffer is clean), Cancel keeps the edit. +static void casePromptRecent(void) { + const char *answers[] = { BTN_CANCEL, BTN_NO, BTN_YES }; + + for (int32_t a = 0; a < (int32_t)(sizeof(answers) / sizeof(answers[0])); a++) { + char caseName[LINE_BUF]; + + snprintf(caseName, sizeof(caseName), "recent project with a dirty module prompts (%s)", answers[a]); + tCase(caseName); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_MAIN); + editorTypeAtEnd(EDIT_MARK_DIRTY); + TEST_TRUE(sProject.files[FILE_MAIN].modified); + + DialogPlanT plan; + + dialogPlanInit(&plan, answers[a]); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_RECENT_BASE); + tDialogScript(NULL, NULL); + TEST_EQ(plan.prompts, 1); + TEST_EQ(plan.errors, 0); + TEST_TRUE(hasProject()); + + if (!hasProject()) { + ideExit(); + tShutdown(); + continue; + } + + if (strcmp(answers[a], BTN_CANCEL) == 0) { + TEST_TRUE(sProject.files[FILE_MAIN].modified); + TEST_TRUE(strstr(wgtGetText(sEditor), EDIT_MARK_DIRTY) != NULL); + TEST_TRUE(!fileHas(sBasPath, EDIT_MARK_DIRTY)); + } else if (strcmp(answers[a], BTN_NO) == 0) { + TEST_TRUE(!sProject.files[FILE_MAIN].modified); + TEST_TRUE(!fileHas(sBasPath, EDIT_MARK_DIRTY)); + TEST_TRUE(sProject.files[FILE_MAIN].buffer != NULL); + TEST_PTR_EQ(sProject.files[FILE_MAIN].buffer ? strstr(sProject.files[FILE_MAIN].buffer, EDIT_MARK_DIRTY) : NULL, NULL); + } else { + TEST_TRUE(!sProject.files[FILE_MAIN].modified); + TEST_TRUE(fileHas(sBasPath, EDIT_MARK_DIRTY)); + } + + ideExit(); + tShutdown(); + } +} + + +// The recent list is written to the INI on open and rebuilt into the +// File menu on the next start. +static void caseRecentPersistence(void) { + tCase("recent list persists across IDE restarts"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + TEST_EQ((int32_t)arrlen(sRecentFiles), 1); + ideExit(); + + PrefsHandleT *disk = prefsLoad(sPrefsPath); + + TEST_TRUE(disk != NULL); + + if (disk) { + char key[IDE_PREF_KEY_BUF]; + + snprintf(key, sizeof(key), PREF_KEY_RECENT_FMT, 0L); + TEST_STR_EQ(prefsGetString(disk, PREF_SEC_RECENT, key, ""), sDbpPath); + prefsClose(disk); + } + + // Restart without re-priming the INI: the entry and menu item come back + ideStart(false); + TEST_EQ((int32_t)arrlen(sRecentFiles), 1); + TEST_STR_EQ(arrlen(sRecentFiles) > 0 ? sRecentFiles[0] : "", sDbpPath); + TEST_TRUE(sFileMenu != NULL); + TEST_TRUE(sFileMenu && sFileMenu->itemCount > sFileMenuBase); + TEST_EQ(sFileMenu ? sFileMenu->items[sFileMenu->itemCount - 1].id : -1, CMD_RECENT_BASE); + + // And it opens the project again + openViaRecent(); + TEST_STR_EQ(sProject.name, FIXTURE_NAME); + ideExit(); + tShutdown(); +} + + +// Remove File on a modified, non-active module: only that file is +// written, the active form keeps its unsaved edit, and the editor stays +// bound to the form after the index shift. +static void caseRemoveFile(void) { + tCase("Remove File saves the removed file only; editor binding survives"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + + treeOpenFile(FILE_MAIN); + editorTypeAtEnd(EDIT_MARK_BAS); + TEST_TRUE(sProject.files[FILE_MAIN].modified); + + treeOpenFile(FILE_FORM); + TEST_EQ(sProject.activeFileIdx, FILE_FORM); + TEST_EQ(sEditorFileIdx, FILE_FORM); + editorTypeAtEnd(EDIT_MARK_FRM); + TEST_TRUE(sProject.files[FILE_FORM].modified); + + treeSelectFile(FILE_MAIN); + TEST_EQ(prjGetSelectedFileIdx(), FILE_MAIN); + + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_YES); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_REMOVE); + tDialogScript(NULL, NULL); + TEST_EQ(plan.prompts, 1); + TEST_STR_EQ(plan.lastTitle, IDE_MAIN_TITLE); + + TEST_EQ(sProject.fileCount, 1); + TEST_TRUE(sProject.files[0].isForm); + TEST_TRUE(sProject.files[0].modified); + TEST_TRUE(fileHas(sBasPath, EDIT_MARK_BAS)); + TEST_TRUE(!fileHas(sFrmPath, EDIT_MARK_FRM)); + TEST_EQ(sProject.activeFileIdx, 0); + TEST_EQ(sEditorFileIdx, 0); + TEST_TRUE(strstr(getFullSource(), EDIT_MARK_FRM) != NULL); + + // The editor still edits the form + editorTypeAtEnd(EDIT_MARK_DIRTY); + TEST_TRUE(sProject.files[0].modified); + ideMenu(CMD_SAVE_ALL); + TEST_TRUE(fileHas(sFrmPath, EDIT_MARK_FRM)); + TEST_TRUE(fileHas(sFrmPath, EDIT_MARK_DIRTY)); + TEST_TRUE(!sProject.files[0].modified); + + ideExit(); + tShutdown(); +} + + +// Renaming a control in the Properties grid rewrites its event handler +// in the form code -- with the code window closed (the designer's copy +// of the code is the only one) and with the code editor bound to the +// form -- marks the form modified, and saves through to disk. +static void caseRenameControl(void) { + tCase("rename a control in Properties rewrites code and marks modified"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_FORM); + TEST_TRUE(sPropsWin != NULL); + TEST_TRUE(sDesigner.form != NULL); + TEST_PTR_EQ(sCodeWin, NULL); + + if (!sPropsWin || !sDesigner.form) { + ideExit(); + tShutdown(); + return; + } + + // Code window closed: the rename must still reach the form's code + renameViaProperties(FIXTURE_BUTTON_NAME, FIXTURE_RENAMED); + TEST_STR_EQ(sDesigner.form->controls[0]->name, FIXTURE_RENAMED); + TEST_TRUE(sDesigner.form->code && strstr(sDesigner.form->code, FIXTURE_RENAMED_HANDLER) != NULL); + TEST_TRUE(sDesigner.form->code && strstr(sDesigner.form->code, FIXTURE_HANDLER) == NULL); + TEST_TRUE(hasUnsavedData()); + TEST_TRUE(sDesigner.form->dirty); + + ideMenu(CMD_SAVE); + TEST_TRUE(fileHas(sFrmPath, FIXTURE_RENAMED_HANDLER)); + TEST_TRUE(fileHas(sFrmPath, "Begin CommandButton " FIXTURE_RENAMED)); + TEST_TRUE(!fileHas(sFrmPath, FIXTURE_BUTTON_NAME)); + TEST_TRUE(!hasUnsavedData()); + + // Code editor bound to the form: the editor text follows the rename + ideMenu(CMD_VIEW_CODE); + TEST_EQ(sEditorFileIdx, FILE_FORM); + showProc(0); + TEST_TRUE(strstr(wgtGetText(sEditor), FIXTURE_RENAMED_HANDLER) != NULL); + renameViaProperties(FIXTURE_RENAMED, FIXTURE_BUTTON_NAME); + TEST_STR_EQ(sDesigner.form->controls[0]->name, FIXTURE_BUTTON_NAME); + showProc(0); + TEST_TRUE(strstr(wgtGetText(sEditor), FIXTURE_HANDLER) != NULL); + TEST_TRUE(strstr(getFullSource(), FIXTURE_HANDLER) != NULL); + TEST_PTR_EQ(strstr(getFullSource(), FIXTURE_RENAMED_HANDLER), NULL); + TEST_TRUE(hasUnsavedData()); + + ideMenu(CMD_SAVE); + TEST_TRUE(fileHas(sFrmPath, FIXTURE_HANDLER)); + TEST_TRUE(!fileHas(sFrmPath, FIXTURE_RENAMED)); + TEST_TRUE(!hasUnsavedData()); + + ideExit(); + tShutdown(); +} + + +// Run (F5), click the form's button, see its PRINT in the Output pane; +// while running, Run/Debug/New/Open/Close/Make are all refused; Stop +// returns to idle within a few frames. +static void caseRunClickAndRefusals(void) { + tCase("run, click the button, PRINT output; nested run and Close Project refused; stop"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + + runScript(stepRunClick, CMD_RUN); + + TEST_EQ(sRunStep, 2); + TEST_EQ(sDbgState, DBG_IDLE); + TEST_PTR_EQ(sVm, NULL); + TEST_TRUE(strstr(sOutputBuf, FIXTURE_OUTPUT_TEXT) != NULL); + TEST_TRUE(strstr(sOutputBuf, FIXTURE_OUTPUT_VALUE) != NULL); + + if (sOutput) { + TEST_TRUE(strstr(wgtGetText(sOutput), FIXTURE_OUTPUT_TEXT) != NULL); + } + + TEST_TRUE(sStopFrame >= 0); + TEST_TRUE(sAc->frameCount - sStopFrame <= STOP_FRAME_LIMIT); + TEST_EQ(sUnexpectedModals, 0); + TEST_TRUE(hasProject()); + tPump(SETTLE_FRAMES); + TEST_PTR_EQ(tFindWindow(FIXTURE_FORM_TITLE), NULL); + + ideExit(); + tShutdown(); +} + + +// Click the running form's button through the platform mouse queue. +static void clickFixtureButton(void) { + WindowT *formWin = tFindWindow(FIXTURE_FORM_TITLE); + WidgetT *btn = formWin ? tFindByText(formWin->widgetRoot, FIXTURE_BUTTON) : NULL; + int32_t sx = 0; + int32_t sy = 0; + + TEST_TRUE(btn != NULL); + + if (btn) { + tWidgetCenter(btn, &sx, &sy); + tClick(sx, sy); + } +} + + +// tDialogScript handler: answer whichever modal is up according to the +// plan, once per modal window. +static void dialogAnswer(AppContextT *ctx, WindowT *modal, void *userCtx) { + DialogPlanT *plan = (DialogPlanT *)userCtx; + + (void)ctx; + + if (modal->id == plan->lastModalId) { + return; + } + + plan->lastModalId = modal->id; + snprintf(plan->lastTitle, sizeof(plan->lastTitle), "%s", modal->title); + + if (strcmp(modal->title, IDE_MAIN_TITLE) == 0) { + plan->prompts++; + TEST_TRUE(tDialogClickButton(modal, plan->promptButton)); + return; + } + + if (strcmp(modal->title, TITLE_REMOVE_FILE) == 0) { + TEST_TRUE(tDialogClickButton(modal, plan->promptButton)); + return; + } + + if (strcmp(modal->title, TITLE_NEW_PROJECT) == 0 || strcmp(modal->title, TITLE_EDIT_PROPERTY) == 0) { + WidgetT *input = findOfClass(modal->widgetRoot, sTextInputClass, 0); + + if (plan->useSpinner) { + WidgetT *spinner = findOfClass(modal->widgetRoot, sSpinnerClass, 0); + + TEST_TRUE(spinner != NULL); + + if (spinner) { + wgtSpinnerSetValue(spinner, plan->intValue); + } + + TEST_TRUE(tDialogClickButton(modal, BTN_OK)); + return; + } + + if (plan->inputText && input) { + wgtSetText(input, plan->inputText); + TEST_TRUE(tDialogClickButton(modal, BTN_OK)); + } else { + TEST_TRUE(tDialogClickButton(modal, BTN_CANCEL)); + } + + return; + } + + if (strcmp(modal->title, "Confirm Save") == 0 || strcmp(modal->title, "Confirm Create") == 0) { + TEST_TRUE(tDialogClickButton(modal, BTN_YES)); + return; + } + + if (strcmp(modal->title, TITLE_OPEN_PROJECT) == 0 || strcmp(modal->title, TITLE_ADD_FILE) == 0 || strcmp(modal->title, TITLE_MAKE_EXE) == 0 || (plan->fileTitle && strcmp(modal->title, plan->fileTitle) == 0)) { + WidgetT *pathInput = findOfClass(modal->widgetRoot, sTextInputClass, 0); + WidgetT *nameInput = findOfClass(modal->widgetRoot, sTextInputClass, 1); + + if (plan->fileName && pathInput && nameInput) { + wgtSetText(pathInput, plan->fileDir); + + if (pathInput->onKeyDown) { + pathInput->onKeyDown(pathInput, KEY_ENTER, 0); + } + + wgtSetText(nameInput, plan->fileName); + TEST_TRUE(tDialogClickButton(modal, plan->fileButton)); + } else { + TEST_TRUE(tDialogClickButton(modal, BTN_CANCEL)); + } + + return; + } + + if (plan->choiceTitle && strcmp(modal->title, plan->choiceTitle) == 0) { + WidgetT *listBox = findOfClass(modal->widgetRoot, sListBoxClass, 0); + + TEST_TRUE(listBox != NULL); + + if (listBox && plan->choiceIndex >= 0) { + wgtListBoxSetSelected(listBox, plan->choiceIndex); + TEST_TRUE(tDialogClickButton(modal, BTN_OK)); + } else { + TEST_TRUE(tDialogClickButton(modal, BTN_CANCEL)); + } + + return; + } + + if (strcmp(modal->title, TITLE_BUILD_MODE) == 0) { + WidgetT *listBox = findOfClass(modal->widgetRoot, sListBoxClass, 0); + + TEST_TRUE(listBox != NULL); + + if (listBox) { + wgtListBoxSetSelected(listBox, plan->choiceIndex); + } + + TEST_TRUE(tDialogClickButton(modal, BTN_OK)); + return; + } + + if (strcmp(modal->title, TITLE_ERROR) == 0 || strcmp(modal->title, TITLE_PROPERTIES) == 0 || strcmp(modal->title, TITLE_INVALID_ICON) == 0) { + plan->errors++; + TEST_TRUE(tDialogClickButton(modal, BTN_OK)); + return; + } + + if (plan->custom) { + plan->custom(modal); + return; + } + + // Anything unexpected: dismiss it and fail the case + TEST_STR_EQ(modal->title, "(expected dialog)"); + + if (!tDialogClickButton(modal, BTN_CANCEL)) { + tDialogClickButton(modal, BTN_OK); + } +} + + +static void dialogPlanInit(DialogPlanT *plan, const char *promptButton) { + memset(plan, 0, sizeof(*plan)); + plan->promptButton = promptButton; + plan->lastModalId = -1; +} + + +// Give the code editor window and widget keyboard focus. +static void editorFocus(void) { + TEST_TRUE(sCodeWin != NULL && sEditor != NULL); + + if (sCodeWin && sEditor) { + dvxRaiseWindow(sAc, sCodeWin); + wgtSetFocused(sEditor); + tPump(1); + } +} + + +// Type text at the end of the editor's current buffer (a user edit, so +// onEditorChange runs and marks the file modified). +static void editorTypeAtEnd(const char *text) { + editorFocus(); + + if (sEditor) { + wgtTextAreaCursorToEnd(sEditor); + tType(text); + } +} + + +static bool fileHas(const char *path, const char *needle) { + int32_t len = 0; + char *data = platformReadFile(path, &len); + bool hit = data && strstr(data, needle) != NULL; + + free(data); + return hit; +} + + +static bool filesIdentical(const char *pathA, const char *pathB) { + int32_t lenA = 0; + int32_t lenB = 0; + char *dataA = platformReadFile(pathA, &lenA); + char *dataB = platformReadFile(pathB, &lenB); + bool same = dataA && dataB && lenA == lenB && lenA > 0 && memcmp(dataA, dataB, (size_t)lenA) == 0; + + if (!same) { + printf(" sizes: %s=%ld %s=%ld\n", pathA, (long)lenA, pathB, (long)lenB); + } + + free(dataA); + free(dataB); + return same; +} + + +// nth widget (depth-first) under root whose class matches wclass. +static WidgetT *findOfClass(WidgetT *root, const void *wclass, int32_t nth) { + if (!root) { + return NULL; + } + + WidgetT **stack = NULL; // stb_ds: pending subtrees + WidgetT *hit = NULL; + int32_t seen = 0; + + arrput(stack, root); + + while (arrlen(stack) > 0 && !hit) { + WidgetT *w = arrpop(stack); + + if (w->wclass == wclass) { + if (seen == nth) { + hit = w; + } + + seen++; + } + + // Push children in reverse so the first child is visited first + WidgetT **kids = NULL; + + for (WidgetT *c = w->firstChild; c; c = c->nextSibling) { + arrput(kids, c); + } + + for (int32_t i = (int32_t)arrlen(kids) - 1; i >= 0; i--) { + arrput(stack, kids[i]); + } + + arrfree(kids); + } + + arrfree(stack); + return hit; +} + + +// Project tree item for a file index (items carry the index in userData +// and a double-click handler; the group nodes carry -1). +static WidgetT *findTreeItem(WidgetT *root, int32_t fileIdx) { + if (!root) { + return NULL; + } + + if (root->onDblClick && (int32_t)(intptr_t)root->userData == fileIdx) { + return root; + } + + for (WidgetT *c = root->firstChild; c; c = c->nextSibling) { + WidgetT *hit = findTreeItem(c, fileIdx); + + if (hit) { + return hit; + } + } + + return NULL; +} + + +// Window whose title starts with prefix (dirty windows carry " *"). +static WindowT *findWindowPrefix(const char *prefix) { + for (int32_t i = 0; i < sAc->stack.count; i++) { + WindowT *win = sAc->stack.windows[i]; + + if (strncmp(win->title, prefix, strlen(prefix)) == 0) { + return win; + } + } + + return NULL; +} + + +// Copy the fixture project into /prj and record its paths. +static void fixturePrepare(void) { + const char *names[] = { FIXTURE_DBP, FIXTURE_BAS, FIXTURE_FRM }; + + snprintf(sProjectDir, sizeof(sProjectDir), "%s" DVX_PATH_SEP PROJECT_DIR_NAME, tTempDir()); + TEST_EQ(platformMkdirRecursive(sProjectDir), 0); + + for (int32_t i = 0; i < (int32_t)(sizeof(names) / sizeof(names[0])); i++) { + char src[DVX_MAX_PATH]; + char dst[DVX_MAX_PATH]; + int32_t len = 0; + + snprintf(src, sizeof(src), "%s" DVX_PATH_SEP "%s", IDETEST_FIXTURE_DIR, names[i]); + snprintf(dst, sizeof(dst), "%s" DVX_PATH_SEP "%s", sProjectDir, names[i]); + + char *data = platformReadFile(src, &len); + + TEST_TRUE(data != NULL); + + if (data) { + TEST_TRUE(tWriteFile(dst, data, len)); + free(data); + } + } + + snprintf(sDbpPath, sizeof(sDbpPath), "%s" DVX_PATH_SEP FIXTURE_DBP, sProjectDir); + snprintf(sBasPath, sizeof(sBasPath), "%s" DVX_PATH_SEP FIXTURE_BAS, sProjectDir); + snprintf(sFrmPath, sizeof(sFrmPath), "%s" DVX_PATH_SEP FIXTURE_FRM, sProjectDir); +} + + +// File / Exit, answering any save prompt with No. +static void ideExit(void) { + dialogPlanInit(&sExitPlan, BTN_NO); + tDialogScript(dialogAnswer, &sExitPlan); + ideMenu(CMD_EXIT); + tDialogScript(NULL, NULL); + TEST_PTR_EQ(sWin, NULL); + tPump(SETTLE_FRAMES); +} + + +// Send a menu command to the main window and let the frame settle. +static void ideMenu(int32_t cmd) { + TEST_TRUE(sWin != NULL); + + if (sWin) { + onMenu(sWin, cmd); + } + + tPump(1); +} + + +// Run appMain against a writable config dir under the temp dir. With +// primeRecent the INI is seeded so the project appears in File / Recent. +static void ideStart(bool primeRecent) { + char configDir[DVX_MAX_PATH]; + + snprintf(configDir, sizeof(configDir), "%s" DVX_PATH_SEP CONFIG_DIR_NAME, tTempDir()); + snprintf(sPrefsPath, sizeof(sPrefsPath), "%s" DVX_PATH_SEP IDE_PREFS_FILE, configDir); + TEST_EQ(platformMkdirRecursive(configDir), 0); + + if (primeRecent) { + PrefsHandleT *h = prefsCreate(); + char key[IDE_PREF_KEY_BUF]; + + snprintf(key, sizeof(key), PREF_KEY_RECENT_FMT, 0L); + prefsSetString(h, PREF_SEC_RECENT, key, sDbpPath); + TEST_TRUE(prefsSaveAs(h, sPrefsPath)); + prefsClose(h); + } + + memset(&sDxe, 0, sizeof(sDxe)); + sDxe.shellCtx = tCtx(); + sDxe.appId = 1; + snprintf(sDxe.appPath, sizeof(sDxe.appPath), "%s", sBascompPath); + snprintf(sDxe.appDir, sizeof(sDxe.appDir), "%s", tTempDir()); + snprintf(sDxe.configDir, sizeof(sDxe.configDir), "%s", configDir); + + probeClasses(); + TEST_EQ(appMain(&sDxe), 0); + tPump(SETTLE_FRAMES); + TEST_TRUE(sWin != NULL); + TEST_PTR_EQ(tFindWindow(IDE_MAIN_TITLE), sWin); +} + + +// 1-based line that contains needle, or -1. +static int32_t lineOf(const char *text, const char *needle) { + const char *hit = text ? strstr(text, needle) : NULL; + + if (!hit) { + return -1; + } + + int32_t line = 1; + + for (const char *p = text; p < hit; p++) { + if (*p == '\n') { + line++; + } + } + + return line; +} + + +// Copy the 1-based line of text into out (without the newline). +static void lineText(const char *text, int32_t line, char *out, int32_t outSize) { + const char *p = text; + + for (int32_t ln = 1; *p && ln < line; ln++) { + const char *nl = strchr(p, '\n'); + + if (!nl) { + p += strlen(p); + break; + } + + p = nl + 1; + } + + int32_t n = 0; + + while (p[n] && p[n] != '\n' && n < outSize - 1) { + out[n] = p[n]; + n++; + } + + out[n] = '\0'; +} + + +static int32_t listRowByName(WidgetT *list, const char *name) { + if (!list) { + return -1; + } + + int32_t rows = wgtListViewGetRowCount(list); + + for (int32_t r = 0; r < rows; r++) { + const char *cell = wgtListViewGetCell(list, r, 0); + + if (cell && strcmp(cell, name) == 0) { + return r; + } + } + + return -1; +} + + +// File / opens the seeded fixture project. +static void openViaRecent(void) { + TEST_EQ((int32_t)arrlen(sRecentFiles), 1); + ideMenu(CMD_RECENT_BASE); + tPump(SETTLE_FRAMES); + TEST_TRUE(hasProject()); + TEST_STR_EQ(sProject.name, FIXTURE_NAME); +} + + +static void prefsCheckClamped(WindowT *modal) { + char expected[IDE_TAB_WIDTH_BUF]; + + snprintf(expected, sizeof(expected), "%d", (int)IDE_MAX_TAB_WIDTH); + TEST_STR_EQ(wgtGetText(sPrefsDlg.tabWidthInput), expected); + TEST_TRUE(tDialogClickButton(modal, BTN_CANCEL)); +} + + +static void prefsFill(WindowT *modal) { + TEST_TRUE(sPrefsDlg.tabWidthInput != NULL && sPrefsDlg.useSpaces != NULL && sPrefsDlg.defAuthor != NULL); + wgtSetText(sPrefsDlg.tabWidthInput, PREFS_TAB_WIDTH_TEXT); + wgtCheckboxSetChecked(sPrefsDlg.useSpaces, false); + wgtSetText(sPrefsDlg.defAuthor, PREFS_AUTHOR); + TEST_TRUE(tDialogClickButton(modal, BTN_OK)); +} + + +static void prefsFillBig(WindowT *modal) { + wgtSetText(sPrefsDlg.tabWidthInput, PREFS_TAB_WIDTH_BIG); + TEST_TRUE(tDialogClickButton(modal, BTN_OK)); +} + + +static void prefsVerify(WindowT *modal) { + TEST_STR_EQ(wgtGetText(sPrefsDlg.tabWidthInput), PREFS_TAB_WIDTH_TEXT); + TEST_TRUE(!wgtCheckboxIsChecked(sPrefsDlg.useSpaces)); + TEST_STR_EQ(wgtGetText(sPrefsDlg.defAuthor), PREFS_AUTHOR); + TEST_TRUE(tDialogClickButton(modal, BTN_CANCEL)); +} + + +// Learn the class pointers of the widget types the dialogs are built +// from, so modal contents can be located without names. +static void probeClasses(void) { + WindowT *win = dvxCreateWindow(tCtx(), "probe", 0, 0, SCREEN_W / 4, SCREEN_H / 4, false); + WidgetT *root = win ? wgtInitWindow(tCtx(), win) : NULL; + + TEST_TRUE(root != NULL); + + if (root) { + sTextInputClass = wgtTextInput(root, LINE_BUF)->wclass; + sListBoxClass = wgtListBox(root)->wclass; + sListViewClass = wgtListView(root)->wclass; + sSpinnerClass = wgtSpinner(root, 0, LINE_BUF, 1)->wclass; + sTreeViewClass = wgtTreeView(root)->wclass; + sDropdownClass = wgtDropdown(root)->wclass; + sCheckboxClass = wgtCheckbox(root, "probe")->wclass; + sTextAreaClass = wgtTextArea(root, LINE_BUF)->wclass; + dvxDestroyWindow(tCtx(), win); + tPump(SETTLE_FRAMES); + } +} + + +// Rename the first control through the Properties grid: select it, +// double-click the Name row, answer the input box. +static void renameViaProperties(const char *oldName, const char *newName) { + sDesigner.selectedIdx = 0; + prpRefresh(&sDesigner); + tPump(1); + + WidgetT *list = findOfClass(sPropsWin->widgetRoot, sListViewClass, 0); + + TEST_TRUE(list != NULL && list->onDblClick != NULL); + + int32_t row = listRowByName(list, PROP_ROW_NAME); + + TEST_TRUE(row >= 0); + TEST_STR_EQ(row >= 0 ? wgtListViewGetCell(list, row, 1) : "", oldName); + + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_NO); + plan.inputText = newName; + tDialogScript(dialogAnswer, &plan); + + if (list && list->onDblClick && row >= 0) { + wgtListViewSetSelected(list, row); + list->onDblClick(list); + } + + tPump(1); + tDialogScript(NULL, NULL); + TEST_STR_EQ(plan.lastTitle, TITLE_EDIT_PROPERTY); + TEST_EQ(plan.errors, 0); +} + + +// Idle-frame driver for scripted runs: one step per idle frame from +// inside runModule's nested loop. Any modal that appears during a run +// is unexpected: dismiss it so the suite cannot hang. +static void runIdle(void *userCtx) { + (void)userCtx; + + if (sAc->modalWindow && sRunInputText && strcmp(sAc->modalWindow->title, IDE_MAIN_TITLE) == 0) { + WidgetT *input = findOfClass(sAc->modalWindow->widgetRoot, sTextInputClass, 0); + + if (input) { + wgtSetText(input, sRunInputText); + tDialogClickButton(sAc->modalWindow, BTN_OK); + return; + } + } + + if (sAc->modalWindow) { + sUnexpectedModals++; + + if (!tDialogClickButton(sAc->modalWindow, BTN_CANCEL) && !tDialogClickButton(sAc->modalWindow, BTN_NO)) { + tDialogClickButton(sAc->modalWindow, BTN_OK); + } + + return; + } + + if (sAc->frameCount - sRunStartFrame > RUN_FRAME_LIMIT) { + if (sDbgState != DBG_IDLE) { + TEST_TRUE(!"run script timed out"); + onMenu(sWin, CMD_STOP); + } + + return; + } + + if (sRunFn) { + sRunFn(); + } +} + + +// Install fn as the idle script and issue the (blocking) run command. +static void runScript(RunStepFnT fn, int32_t cmd) { + sRunFn = fn; + sRunStep = 0; + sStopFrame = -1; + sUnexpectedModals = 0; + sRunStartFrame = sAc->frameCount; + sAc->idleCallback = runIdle; + sAc->idleCtx = NULL; + onMenu(sWin, cmd); + sAc->idleCallback = NULL; + sAc->idleCtx = NULL; + sRunFn = NULL; + tPump(1); +} + + +// Copy bin/host/bascomp next to the test binary: it is both the IDE's +// "own executable" (STUB / noicon resources for Make Executable) and the +// reference compiler, and a concurrent rebuild of bin/host must not +// change it mid-suite. +static void snapshotBascomp(void) { + int32_t len = 0; + char *data = platformReadFile(IDETEST_BASCOMP, &len); + char cwd[DVX_MAX_PATH]; + + TEST_TRUE(getcwd(cwd, sizeof(cwd)) != NULL); + snprintf(sBascompPath, sizeof(sBascompPath), "%s" DVX_PATH_SEP BASCOMP_SNAPSHOT, cwd); + TEST_TRUE(data != NULL && len > 0); + + if (data) { + TEST_TRUE(tWriteFile(sBascompPath, data, len)); + TEST_EQ(chmod(sBascompPath, EXEC_MODE), 0); + free(data); + } +} + + +static int32_t sourceMapStart(int32_t fileIdx) { + for (int32_t m = 0; m < sProject.sourceMapCount; m++) { + if (sProject.sourceMap[m].fileIdx == fileIdx) { + return sProject.sourceMap[m].startLine; + } + } + + return -1; +} + + +// Debug run: click Go, pause on the handler breakpoint, inspect, stop. +static void stepBreakInHandler(void) { + switch (sRunStep) { + case 0: + if (!tFindWindow(FIXTURE_FORM_TITLE)) { + return; + } + + sRunStep = 1; + clickFixtureButton(); + break; + + case 1: { + if (sDbgState != DBG_PAUSED) { + return; + } + + sRunStep = 2; + TEST_TRUE(sVm != NULL); + TEST_EQ((int32_t)arrlen(sVmBreakpoints), 1); + TEST_EQ(sVm ? sVm->currentLine : -1, arrlen(sVmBreakpoints) > 0 ? sVmBreakpoints[0] : -1); + TEST_TRUE(sVm && sVm->debugPaused); + + // The editor was navigated to the handler; the debug line is editor-local + TEST_EQ(sEditorFileIdx, FILE_FORM); + TEST_EQ(sCurProcIdx, 0); + TEST_EQ(sDbgCurrentLine, sHandlerLine); + TEST_EQ(sEditor ? wgtTextAreaGetCursorLine(sEditor) : -1, sHandlerLine); + + // Immediate window: PRINT a global while paused + ideMenu(CMD_WIN_IMM); + TEST_TRUE(sImmWin != NULL && sImmediate != NULL); + + if (sImmWin && sImmediate) { + dvxRaiseWindow(sAc, sImmWin); + wgtSetFocused(sImmediate); + tPump(1); + tType(KW_PRINT " " FIXTURE_GLOBAL_NAME); + tKeyEnter(); + + const char *immText = wgtGetText(sImmediate); + const char *result = immText ? strstr(immText, "\n") : NULL; + + TEST_TRUE(result != NULL && strstr(result, FIXTURE_PAUSED_VALUE) != NULL); + } + + // Watch window + ideMenu(CMD_WIN_WATCH); + TEST_TRUE(sWatchWin != NULL && sWatchInput != NULL && sWatchList != NULL); + + if (sWatchInput && sWatchList) { + wgtSetText(sWatchInput, FIXTURE_GLOBAL_NAME); + onWatchInputKeyDown(sWatchInput, KEY_ENTER, 0); + tPump(1); + TEST_EQ(wgtListViewGetRowCount(sWatchList), 1); + TEST_STR_EQ(wgtListViewGetCell(sWatchList, 0, 0), FIXTURE_GLOBAL_NAME); + TEST_TRUE(strstr(wgtListViewGetCell(sWatchList, 0, 1), FIXTURE_PAUSED_VALUE) != NULL); + } + + // Locals and Call Stack were auto-shown on pause + TEST_TRUE(sLocalsWin != NULL && sLocalsList != NULL); + TEST_TRUE(sLocalsList && listRowByName(sLocalsList, FIXTURE_GLOBAL_NAME) >= 0); + TEST_TRUE(sCallStackWin != NULL && sCallStackList != NULL); + TEST_TRUE(sCallStackList && wgtListViewGetRowCount(sCallStackList) >= 1); + TEST_STR_EQ(sCallStackList && wgtListViewGetRowCount(sCallStackList) >= 1 ? wgtListViewGetCell(sCallStackList, 0, 0) : "", FIXTURE_HANDLER); + + // Nested run is refused while a session is active + BasVmT *before = sVm; + + onMenu(sWin, CMD_PRJ_CLOSE); + onMenu(sWin, CMD_MAKE_EXE); + TEST_PTR_EQ(sVm, before); + TEST_TRUE(hasProject()); + TEST_EQ(sDbgState, DBG_PAUSED); + + sRunStep = 3; + sStopFrame = sAc->frameCount; + onMenu(sWin, CMD_STOP); + break; + } + + default: + break; + } +} + + +// Debug run: the module-level breakpoint pauses before forms load. +static void stepModuleBreakpoint(void) { + if (sRunStep != 0 || sDbgState != DBG_PAUSED) { + return; + } + + sRunStep = 1; + TEST_TRUE(sVm != NULL); + TEST_EQ(sVm ? sVm->currentLine : -1, arrlen(sVmBreakpoints) > 0 ? sVmBreakpoints[0] : -1); + TEST_EQ(sEditorFileIdx, FILE_MAIN); + TEST_EQ(sCurProcIdx, -1); + TEST_EQ(sDbgCurrentLine, arrlen(sBreakpoints) > 0 ? sBreakpoints[0].codeLine : -1); + onMenu(sWin, CMD_STOP); +} + + +// Plain run: click Go, check the refusals, stop. +static void stepRunClick(void) { + switch (sRunStep) { + case 0: + if (!tFindWindow(FIXTURE_FORM_TITLE)) { + return; + } + + sRunStep = 1; + TEST_EQ(sDbgState, DBG_RUNNING); + clickFixtureButton(); + break; + + case 1: { + if (!strstr(sOutputBuf, FIXTURE_OUTPUT_TEXT)) { + return; + } + + BasVmT *before = sVm; + + TEST_TRUE(before != NULL); + onMenu(sWin, CMD_RUN); + onMenu(sWin, CMD_DEBUG); + onMenu(sWin, CMD_RUN_NOCMP); + onMenu(sWin, CMD_PRJ_CLOSE); + onMenu(sWin, CMD_PRJ_NEW); + onMenu(sWin, CMD_PRJ_OPEN); + onMenu(sWin, CMD_OPEN); + onMenu(sWin, CMD_MAKE_EXE); + TEST_PTR_EQ(sVm, before); + TEST_EQ(sDbgState, DBG_RUNNING); + TEST_TRUE(hasProject()); + TEST_TRUE(tFindWindow(FIXTURE_FORM_TITLE) != NULL); + + sRunStep = 2; + sStopFrame = sAc->frameCount; + onMenu(sWin, CMD_STOP); + break; + } + + default: + break; + } +} + + +static WidgetT *treeOfProjectWindow(void) { + WindowT *win = tFindWindow(TITLE_PROJECT_WIN); + + TEST_TRUE(win != NULL && win->widgetRoot != NULL); + + if (!win || !win->widgetRoot) { + return NULL; + } + + return win->widgetRoot->firstChild; +} + + +// Double-click a file in the project tree (its item handler). +static void treeOpenFile(int32_t fileIdx) { + WidgetT *tree = treeOfProjectWindow(); + WidgetT *item = findTreeItem(tree, fileIdx); + + TEST_TRUE(item != NULL); + + if (tree && item) { + wgtTreeViewSetSelected(tree, item); + item->onDblClick(item); + } + + tPump(SETTLE_FRAMES); +} + + +static void treeSelectFile(int32_t fileIdx) { + WidgetT *tree = treeOfProjectWindow(); + WidgetT *item = findTreeItem(tree, fileIdx); + + TEST_TRUE(item != NULL); + + if (tree && item) { + wgtTreeViewSetSelected(tree, item); + } + + tPump(1); +} + + +// Additional coverage cases share every helper above; they are compiled +// as part of this translation unit because ideMain.c may only be +// included once per binary. +#include "testIdeDesign.c" +#include "testIdeDebug.c" + + +int main(void) { + setvbuf(stdout, NULL, _IOLBF, 0); + platformSetLogPath("testIde.log"); + tSuiteBegin("T6 IDE"); + snapshotBascomp(); + TEST_TRUE(tProcessInit(DVXTEST_WIDGET_DIR) > 0); + + caseOpenEditDesignSave(); + caseOpenViaFileDialog(); + casePromptRecent(); + casePromptNewAndOpen(); + caseRemoveFile(); + caseRenameControl(); + caseBreakpointTracking(); + caseRunClickAndRefusals(); + caseBreakInHandler(); + caseMakeExecutable(); + casePreferences(); + caseFindReplace(); + caseRecentPersistence(); + caseCloseDirtyPrompt(); + + runDesignCases(); + runDebugCases(); + + return tSuiteEnd(); +} diff --git a/src/test/ide/testIdeDebug.c b/src/test/ide/testIdeDebug.c new file mode 100644 index 0000000..6dffca5 --- /dev/null +++ b/src/test/ide/testIdeDebug.c @@ -0,0 +1,773 @@ +// 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. + +// testIdeDebug.c -- T6 coverage cases: stepping, watch/locals/call +// stack, immediate assignments, error paths and run-mode extras. +// Compiled as part of testIde.c. + +#define DG_PRJ_NAME "Debug" +#define DG_DBP "debug.dbp" +#define DG_BAS "dbg.bas" +#define DG_FRM "dbgform.frm" +#define DG_FORM_TITLE "Debug Form" +#define DG_CRASH_BTN "Crash" +#define DG_PLAIN_BTN "Plain" +#define DG_BTN_TOPIC "btn.topic" +#define DG_FORM_TOPIC "form.topic" +#define DG_HELPER_BP "counter = localA" +#define DG_CALL_LINE "Call Helper" +#define DG_INPUT_VALUE "5" + +static const char *DG_DBP_TEXT = + "[Project]\n" + "Name = " DG_PRJ_NAME "\n" + "HelpFile = dbg.hlp\n" + "\n" + "[Modules]\n" + "File0 = " DG_BAS "\n" + "Count = 1\n" + "\n" + "[Forms]\n" + "File0 = " DG_FRM "\n" + "Count = 1\n" + "\n" + "[Settings]\n" + "StartupForm = DbgForm\n"; + +static const char *DG_DBP_NOFORM_TEXT = + "[Project]\n" + "Name = " DG_PRJ_NAME "\n" + "\n" + "[Modules]\n" + "File0 = " DG_BAS "\n" + "Count = 1\n"; + +static const char *DG_BAS_TEXT = + "Type PointT\n" + " px As Integer\n" + " py As Integer\n" + "End Type\n" + "Dim Shared counter As Integer\n" + "Dim Shared lng As Long\n" + "Dim Shared sng As Single\n" + "Dim Shared dbl As Double\n" + "Dim Shared msg As String\n" + "Dim Shared flag As Boolean\n" + "Dim Shared zero As Integer\n" + "Dim Shared arr(3) As Integer\n" + "Dim Shared pt As PointT\n" + "counter = 10\n" + "lng = 100000\n" + "sng = 1.5\n" + "dbl = 2.25\n" + "msg = \"hello\"\n" + "flag = True\n" + "arr(1) = 7\n" + "pt.px = 3\n" + "pt.py = 4\n" + "Call Helper\n" + "counter = counter + 0\n" + "\n" + "Sub Helper()\n" + " Dim localA As Integer\n" + " localA = counter + 1\n" + " counter = localA\n" + "End Sub\n"; + +static const char *DG_FRM_TEXT = + "VERSION DVX 1.00\n" + "Begin Form DbgForm\n" + " Caption = \"" DG_FORM_TITLE "\"\n" + " HelpTopic = \"" DG_FORM_TOPIC "\"\n" + " Begin Menu mnuTest\n" + " Caption = \"&Test\"\n" + " End\n" + " Begin CommandButton btnCrash\n" + " Caption = \"" DG_CRASH_BTN "\"\n" + " HelpTopic = \"" DG_BTN_TOPIC "\"\n" + " End\n" + " Begin CommandButton btnPlain\n" + " Caption = \"" DG_PLAIN_BTN "\"\n" + " End\n" + "End\n" + "\n" + "Sub btnCrash_Click()\n" + " counter = 10 \\ zero\n" + "End Sub\n"; + +static void caseDbgBpWindow(void); +static void caseDbgErrors(void); +static void caseDbgRunExtras(void); +static void caseDbgRunHelp(void); +static void caseDbgStepFlow(void); +static void caseDbgWatchImm(void); +static void dgAssertWatch(const char *expr, const char *valueSub); +static void dgHandlerVariant(const char *handlerCode); +static void dgOpen(void); +static void dgPrepare(void); +static void dgStepCrash(void); +static void dgStepExit(void); +static void dgStepFlow(void); +static void dgStepHelp(void); +static void dgStepWatch(void); +static void dgWriteProject(const char *basText, const char *frmText); +void runDebugCases(void); + + +// Breakpoints window: rows, navigation, deletion, invalid lines and +// edit-shift removal. +static void caseDbgBpWindow(void) { + tCase("breakpoints window and toggle validation"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_MAIN); + + const char *text = wgtGetText(sEditor); + + // Invalid lines: comment, blank (past the end), declaration lines. + toggleBreakpointLine(lineOf(text, "' Fixture module")); + TEST_EQ((int32_t)arrlen(sBreakpoints), 0); + showProc(0); + text = wgtGetText(sEditor); + toggleBreakpointLine(lineOf(text, "Sub Helper")); + toggleBreakpointLine(lineOf(text, "End Sub")); + TEST_EQ((int32_t)arrlen(sBreakpoints), 0); + + // Valid: gutter click inside Helper, cursor toggle in General. + onGutterClick(sEditor, lineOf(text, FIXTURE_HELPER_LINE)); + TEST_EQ((int32_t)arrlen(sBreakpoints), 1); + showProc(-1); + editorFocus(); + wgtTextAreaGoToLine(sEditor, lineOf(wgtGetText(sEditor), FIXTURE_GLOBAL_LINE)); + ideMenu(CMD_TOGGLE_BP); + TEST_EQ((int32_t)arrlen(sBreakpoints), 2); + + // Window shows both rows with file/proc/line columns. + ideMenu(CMD_WIN_BREAKPOINTS); + TEST_TRUE(sBreakpointList != NULL); + TEST_EQ(wgtListViewGetRowCount(sBreakpointList), 2); + TEST_STR_EQ(wgtListViewGetCell(sBreakpointList, 0, 0), FIXTURE_BAS); + TEST_TRUE(strstr(wgtListViewGetCell(sBreakpointList, 0, 1), "Helper") != NULL); + + // Double-click navigates to the breakpoint. + wgtListViewSetSelected(sBreakpointList, 0); + onBreakpointListDblClick(sBreakpointList); + tPump(1); + TEST_TRUE(strstr(wgtGetText(sEditor), FIXTURE_HELPER_LINE) != NULL); + + // Delete key removes the selected row. + wgtListViewSetItemSelected(sBreakpointList, 0, true); + onBreakpointListKeyDown(sBreakpointList, KEY_DELETE, 0); + TEST_EQ((int32_t)arrlen(sBreakpoints), 1); + + // Editing lines away above a breakpoint shifts it out of range and + // drops it. + showProc(-1); + editorFocus(); + wgtTextAreaGoToLine(sEditor, 1); + wgtSetText(sEditor, "x = 1"); + onEditorChange(sEditor); + TEST_EQ((int32_t)arrlen(sBreakpoints), 0); + + ideExit(); + tShutdown(); +} + + +// Compile errors (module level, in a handler, unknown widget type, +// member-name typos) and runtime errors (module level and inside an +// event handler) all land in the Output pane with navigation. +static void caseDbgErrors(void) { + tCase("compile and runtime error reporting and navigation"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + + // Compile error inside the Click handler. + treeOpenFile(FILE_FORM); + ideMenu(CMD_VIEW_CODE); + showProc(0); + editorFocus(); + wgtTextAreaGoToLine(sEditor, 2); + tType("garbage@@@"); + runScript(NULL, CMD_RUN); + tPump(SETTLE_FRAMES); + TEST_TRUE(strstr(sOutputBuf, "COMPILE ERROR") != NULL); + TEST_TRUE(strstr(sOutputBuf, "btnGo.Click") != NULL); + TEST_EQ(sDbgState, DBG_IDLE); + + // Member typos are compiled from disk-reloaded projects so the + // compiled source is exactly the variant under test: an unknown + // property and an unknown method on a known control are rejected, + // valid member access plus Form.Show compiles. + dgHandlerVariant("Sub btnGo_Click()\n btnGo.Caphtion = \"x\"\nEnd Sub\n"); + runScript(NULL, CMD_RUN); + tPump(SETTLE_FRAMES); + TEST_TRUE(strstr(sOutputBuf, "COMPILE ERROR") != NULL); + TEST_TRUE(strstr(sOutputBuf, "Unknown property") != NULL); + + dgHandlerVariant("Sub btnGo_Click()\n btnGo.Boggle\nEnd Sub\n"); + runScript(NULL, CMD_RUN); + tPump(SETTLE_FRAMES); + TEST_TRUE(strstr(sOutputBuf, "COMPILE ERROR") != NULL); + TEST_TRUE(strstr(sOutputBuf, "Unknown method") != NULL); + + dgHandlerVariant("Sub btnGo_Click()\n btnGo.Caption = \"t\"\n Form1.Show\nEnd Sub\n"); + TEST_TRUE(compileProject()); + dvxSetBusy(sAc, false); + + // Module-level compile error in main.bas. + treeOpenFile(FILE_MAIN); + showProc(-1); + editorFocus(); + wgtTextAreaCursorToEnd(sEditor); + tType("@@@"); + runScript(NULL, CMD_RUN); + tPump(SETTLE_FRAMES); + TEST_TRUE(strstr(sOutputBuf, "COMPILE ERROR") != NULL); + TEST_TRUE(strstr(sOutputBuf, FIXTURE_BAS) != NULL); + + ideExit(); + tShutdown(); + + // Unknown widget type in a .frm. + tCase("unknown widget type is rejected at compile time"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + + char badPath[DVX_MAX_PATH]; + const char *badFrm = "VERSION DVX 1.00\nBegin Form BadForm\n Begin Bogus b1\n End\nEnd\n"; + + snprintf(badPath, sizeof(badPath), "%s" DVX_PATH_SEP "badtype.frm", sProjectDir); + TEST_TRUE(tWriteFile(badPath, badFrm, (int32_t)strlen(badFrm))); + ideStart(true); + openViaRecent(); + prjAddFile(&sProject, "badtype.frm", true); + prjLoadAllFiles(&sProject, sAc); + runScript(NULL, CMD_RUN); + tPump(SETTLE_FRAMES); + TEST_TRUE(strstr(sOutputBuf, "Bogus") != NULL); + TEST_TRUE(strstr(sOutputBuf, "not registered") != NULL); + ideExit(); + tShutdown(); + + // Runtime error at module level. + tCase("module-level runtime error navigates and reports"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + dgWriteProject( + "Dim Shared zero As Integer\n" + "counter% = 10 \\ zero\n", + NULL); + ideStart(true); + dgOpen(); + runScript(NULL, CMD_RUN); + TEST_TRUE(strstr(sOutputBuf, "RUNTIME ERROR") != NULL); + TEST_EQ(sDbgState, DBG_IDLE); + TEST_PTR_EQ(sVm, NULL); + ideExit(); + tShutdown(); + + // Runtime error inside an event handler. + tCase("event-handler runtime error reports the handler"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + dgWriteProject(NULL, NULL); + ideStart(true); + dgOpen(); + runScript(dgStepCrash, CMD_RUN); + TEST_TRUE(strstr(sOutputBuf, "RUNTIME ERROR") != NULL); + TEST_TRUE(strstr(sOutputBuf, "btnCrash.Click") != NULL); + TEST_TRUE(sCodeWin && sCodeWin->visible); + TEST_EQ(sUnexpectedModals, 0); + ideExit(); + tShutdown(); +} + + +// INPUT callback, cached re-run, refusal statuses, project save status +// and closing the IDE while a program is running. +static void caseDbgRunExtras(void) { + tCase("INPUT box, cached run, status messages"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + dgWriteProject( + "Dim n As Integer\n" + "INPUT n\n" + "PRINT \"got\"; n\n", + NULL); + ideStart(true); + dgOpen(); + + // Status messages for refused commands. + ideMenu(CMD_RUN_NOCMP); + TEST_TRUE(strstr(wgtGetText(sStatus), "No compiled program") != NULL); + + // Run with a scripted INPUT reply. + sRunInputText = DG_INPUT_VALUE; + runScript(NULL, CMD_RUN); + sRunInputText = NULL; + TEST_TRUE(strstr(sOutputBuf, "got") != NULL); + TEST_TRUE(strstr(sOutputBuf, DG_INPUT_VALUE) != NULL); + + // Re-run the cached module without recompiling. + sRunInputText = DG_INPUT_VALUE; + runScript(NULL, CMD_RUN_NOCMP); + sRunInputText = NULL; + TEST_TRUE(strstr(sOutputBuf, "got") != NULL); + + // Project save through the menu. + sProject.dirty = true; + updateProjectMenuState(); + ideMenu(CMD_PRJ_SAVE); + TEST_TRUE(strstr(wgtGetText(sStatus), "Project saved") != NULL); + TEST_TRUE(!sProject.dirty); + + // Output and Immediate windows hide on close and reappear through + // the Window menu (they used to be destroyed, leaving dangling + // pointers). + TEST_TRUE(sOutWin != NULL && sOutWin->onClose != NULL); + sOutWin->onClose(sOutWin); + tPump(1); + TEST_TRUE(sOutWin && !sOutWin->visible); + setOutputText("still alive"); + ideMenu(CMD_WIN_OUTPUT); + TEST_TRUE(sOutWin->visible); + sImmWin->onClose(sImmWin); + tPump(1); + TEST_TRUE(sImmWin && !sImmWin->visible); + ideMenu(CMD_WIN_IMM); + TEST_TRUE(sImmWin->visible); + + ideExit(); + tShutdown(); + + // Make Executable without a project only sets a status. + tCase("Make Executable without a project is refused"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(false); + ideMenu(CMD_MAKE_EXE); + TEST_TRUE(strstr(wgtGetText(sStatus), "Save the project first") != NULL); + ideExit(); + tShutdown(); + + // Closing the IDE while the program runs unwinds cleanly. + tCase("closing the IDE mid-run frees the module and windows"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + runScript(dgStepExit, CMD_RUN); + TEST_PTR_EQ(sWin, NULL); + TEST_PTR_EQ(sVm, NULL); + TEST_PTR_EQ(sCachedModule, NULL); + tPump(SETTLE_FRAMES); + tShutdown(); +} + + +// F1 while a program with its own help file runs: control topic, form +// topic, and the IDE help file coming back afterwards. +static void caseDbgRunHelp(void) { + tCase("runtime F1 topics from the running program"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + dgWriteProject(NULL, NULL); + ideStart(true); + dgOpen(); + runScript(dgStepHelp, CMD_RUN); + TEST_EQ(sRunStep, 2); + TEST_EQ(sUnexpectedModals, 0); + + // Back in the IDE the help file is its own again. + dvxRaiseWindow(sAc, sWin); + tPump(1); + helpQueryHandler(NULL); + TEST_TRUE(strstr(sDxe.helpFile, IDE_HELP_FILE) != NULL); + + ideExit(); + tShutdown(); +} + + +// Step Into from idle, Step Over, Run to Cursor, Step Into a Sub, Step +// Out, then resume free and stop. +static void caseDbgStepFlow(void) { + tCase("step into/over/out and run-to-cursor"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + dgWriteProject(NULL, NULL); + ideStart(true); + dgOpen(); + treeOpenFile(0); + runScript(dgStepFlow, CMD_STEP_INTO); + TEST_EQ(sRunStep, 7); + TEST_EQ(sDbgState, DBG_IDLE); + TEST_PTR_EQ(sVm, NULL); + TEST_EQ(sUnexpectedModals, 0); + ideExit(); + tShutdown(); +} + + +// Pause on a breakpoint inside Helper and inspect everything: locals +// with every value type, call stack depth, watch expressions and +// immediate-window assignments. +static void caseDbgWatchImm(void) { + tCase("locals, call stack, watch and immediate at a breakpoint"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + dgWriteProject(NULL, NULL); + ideStart(true); + dgOpen(); + treeOpenFile(0); + showProc(0); + toggleBreakpointLine(lineOf(wgtGetText(sEditor), DG_HELPER_BP)); + TEST_EQ((int32_t)arrlen(sBreakpoints), 1); + runScript(dgStepWatch, CMD_DEBUG); + TEST_EQ(sRunStep, 2); + TEST_EQ(sDbgState, DBG_IDLE); + TEST_EQ(sUnexpectedModals, 0); + + const char *immText = sImmediate ? wgtGetText(sImmediate) : ""; + + TEST_TRUE(strstr(immText, "counter = 42") != NULL); + TEST_TRUE(strstr(immText, "Cannot assign") != NULL); + TEST_TRUE(strstr(immText, "Error:") != NULL); + + ideExit(); + tShutdown(); +} + + +// Assert one watch row: add expr through the input, check the value. +static void dgAssertWatch(const char *expr, const char *valueSub) { + wgtSetText(sWatchInput, expr); + onWatchInputKeyDown(sWatchInput, KEY_ENTER, 0); + tPump(1); + + int32_t row = (int32_t)arrlen(sWatchExprs) - 1; + + TEST_TRUE(row >= 0); + TEST_STR_EQ(wgtListViewGetCell(sWatchList, row, 0), expr); + + const char *cell = wgtListViewGetCell(sWatchList, row, 1); + + TEST_TRUE(cell != NULL && strstr(cell, valueSub) != NULL); +} + + +// Rewrite the fixture form's code section on disk and reload the +// project, so the next compile sees exactly this handler. +static void dgHandlerVariant(const char *handlerCode) { + char frm[LINE_BUF * 4]; + + snprintf(frm, sizeof(frm), + "VERSION DVX 1.00\n" + "Begin Form Form1\n" + " Caption = \"" FIXTURE_FORM_TITLE "\"\n" + " Begin CommandButton " FIXTURE_BUTTON_NAME "\n" + " Caption = \"" FIXTURE_BUTTON "\"\n" + " End\n" + "End\n" + "\n" + "%s", handlerCode); + TEST_TRUE(tWriteFile(sFrmPath, frm, (int32_t)strlen(frm))); + + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_NO); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_CLOSE); + tDialogScript(NULL, NULL); + openViaRecent(); +} + + +// Open the seeded debug project through File / Recent. +static void dgOpen(void) { + ideMenu(CMD_RECENT_BASE); + tPump(SETTLE_FRAMES); + TEST_TRUE(hasProject()); + TEST_STR_EQ(sProject.name, DG_PRJ_NAME); +} + + +// Debug-fixture variant of fixturePrepare (kept for symmetry). +static void dgPrepare(void) { + dgWriteProject(NULL, NULL); +} + + +// Wait for the form, click Crash, wait for the program to die. +static void dgStepCrash(void) { + if (sRunStep == 0) { + WindowT *formWin = tFindWindow(DG_FORM_TITLE); + + if (!formWin) { + return; + } + + sRunStep = 1; + + WidgetT *btn = tFindByText(formWin->widgetRoot, DG_CRASH_BTN); + int32_t sx = 0; + int32_t sy = 0; + + TEST_TRUE(btn != NULL); + + if (btn) { + tWidgetCenter(btn, &sx, &sy); + tClick(sx, sy); + } + } +} + + +// Close the whole IDE from inside the running program's event pump. +static void dgStepExit(void) { + if (sRunStep == 0 && tFindWindow(FIXTURE_FORM_TITLE)) { + sRunStep = 1; + onMenu(sWin, CMD_EXIT); + } +} + + +// Stepping sequence for caseDbgStepFlow (one action per pause). +static void dgStepFlow(void) { + if (sRunStep == 6) { + if (sDbgState == DBG_RUNNING && tFindWindow(DG_FORM_TITLE)) { + sRunStep = 7; + sStopFrame = sAc->frameCount; + onTbStop(NULL); + } + + return; + } + + if (sDbgState != DBG_PAUSED) { + return; + } + + switch (sRunStep) { + case 0: + sRunStep = 1; + TEST_TRUE(sVm != NULL && sVm->debugPaused); + TEST_TRUE(sDbgCurrentLine > 0); + onMenu(sWin, CMD_STEP_OVER); + break; + case 1: + sRunStep = 2; + onTbStepInto(NULL); + break; + case 2: { + sRunStep = 3; + + int32_t callLine = lineOf(wgtGetText(sEditor), DG_CALL_LINE); + + TEST_TRUE(callLine > 0); + editorFocus(); + wgtTextAreaGoToLine(sEditor, callLine); + onTbRunToCur(NULL); + break; + } + case 3: + sRunStep = 4; + TEST_EQ(wgtTextAreaGetCursorLine(sEditor), sDbgCurrentLine); + onMenu(sWin, CMD_STEP_INTO); + break; + case 4: + sRunStep = 5; + TEST_TRUE(strstr(wgtGetText(sEditor), "localA") != NULL); + onTbStepOut(NULL); + break; + case 5: + sRunStep = 6; + onMenu(sWin, CMD_RUN); + break; + default: + break; + } +} + + +// F1 topics while the program is running. +static void dgStepHelp(void) { + if (sRunStep == 0) { + WindowT *formWin = tFindWindow(DG_FORM_TITLE); + + if (!formWin) { + return; + } + + sRunStep = 1; + dvxRaiseWindow(sAc, formWin); + + WidgetT *btn = tFindByText(formWin->widgetRoot, DG_CRASH_BTN); + + TEST_TRUE(btn != NULL); + + if (btn) { + wgtSetFocused(btn); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, DG_BTN_TOPIC); + TEST_TRUE(strstr(sDxe.helpFile, "dbg.hlp") != NULL); + } + + WidgetT *plain = tFindByText(formWin->widgetRoot, DG_PLAIN_BTN); + + TEST_TRUE(plain != NULL); + + if (plain) { + wgtSetFocused(plain); + } + + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, DG_FORM_TOPIC); + sRunStep = 2; + sStopFrame = sAc->frameCount; + onMenu(sWin, CMD_STOP); + } +} + + +// Inspection sequence at the Helper breakpoint. +static void dgStepWatch(void) { + if (sRunStep != 0 || sDbgState != DBG_PAUSED) { + return; + } + + sRunStep = 1; + + // Locals: every scalar type plus array and UDT rows. + TEST_TRUE(sLocalsList != NULL); + TEST_TRUE(listRowByName(sLocalsList, "localA") >= 0); + + int32_t row = listRowByName(sLocalsList, "lng"); + + TEST_TRUE(row >= 0); + TEST_STR_EQ(row >= 0 ? wgtListViewGetCell(sLocalsList, row, 1) : "", "Long"); + row = listRowByName(sLocalsList, "arr"); + TEST_TRUE(row >= 0); + TEST_TRUE(row >= 0 && strstr(wgtListViewGetCell(sLocalsList, row, 2), "[4]") != NULL); + row = listRowByName(sLocalsList, "msg"); + TEST_TRUE(row >= 0); + TEST_TRUE(row >= 0 && strstr(wgtListViewGetCell(sLocalsList, row, 2), "hello") != NULL); + row = listRowByName(sLocalsList, "flag"); + TEST_TRUE(row >= 0); + TEST_STR_EQ(row >= 0 ? wgtListViewGetCell(sLocalsList, row, 2) : "", "True"); + + // Call stack: Helper on top, called from the module frame. + TEST_TRUE(sCallStackList != NULL); + TEST_TRUE(wgtListViewGetRowCount(sCallStackList) >= 2); + TEST_TRUE(strstr(wgtListViewGetCell(sCallStackList, 0, 0), "Helper") != NULL); + + // Watch expressions. + ideMenu(CMD_WIN_WATCH); + TEST_TRUE(sWatchInput != NULL && sWatchList != NULL); + dgAssertWatch("arr(1)", "7"); + dgAssertWatch("pt.px", "3"); + dgAssertWatch("counter + 1", "11"); + dgAssertWatch("msg", "hello"); + dgAssertWatch("counter +", ""); + + // Edit the first watch row back into the input, then delete one. + int32_t before = (int32_t)arrlen(sWatchExprs); + + wgtListViewSetSelected(sWatchList, 0); + onWatchListDblClick(sWatchList); + TEST_EQ((int32_t)arrlen(sWatchExprs), before - 1); + TEST_STR_EQ(wgtGetText(sWatchInput), "arr(1)"); + wgtSetText(sWatchInput, ""); + wgtListViewSetSelected(sWatchList, 0); + onWatchListKeyDown(sWatchList, KEY_DELETE, 0); + TEST_EQ((int32_t)arrlen(sWatchExprs), before - 2); + + // Immediate-window assignments of every scalar type. + ideMenu(CMD_WIN_IMM); + evaluateImmediate("counter = 42"); + evaluateImmediate("LET lng = 777"); + evaluateImmediate("sng = 2.5"); + evaluateImmediate("dbl = 3.5"); + evaluateImmediate("msg = \"bye\""); + evaluateImmediate("flag = FALSE"); + evaluateImmediate("arr(2) = 9"); + evaluateImmediate("pt.py = 8"); + evaluateImmediate("arr = 5"); + evaluateImmediate("counter * 2"); + evaluateImmediate("PRINT 10 / zero"); + evaluateImmediate("PRINT )("); + + const char *end = NULL; + BasValueT *slot = resolveVarPath("counter", &end); + + TEST_TRUE(slot != NULL); + TEST_EQ(slot ? slot->intVal : -1, 42); + slot = resolveVarPath("arr(2)", &end); + TEST_TRUE(slot != NULL); + TEST_EQ(slot ? slot->intVal : -1, 9); + slot = resolveVarPath("pt.py", &end); + TEST_TRUE(slot != NULL); + TEST_EQ(slot ? slot->intVal : -1, 8); + + // Clearing every breakpoint while the VM is live detaches it. + clearAllBreakpoints(); + TEST_EQ((int32_t)arrlen(sVmBreakpoints), 0); + + sRunStep = 2; + sStopFrame = sAc->frameCount; + onMenu(sWin, CMD_STOP); +} + + +// Write the debug project into a fresh directory. NULL basText selects +// the default module and the full form project; a custom basText with a +// NULL frmText produces a console-style project without any form, so +// the program terminates on its own. +static void dgWriteProject(const char *basText, const char *frmText) { + snprintf(sProjectDir, sizeof(sProjectDir), "%s" DVX_PATH_SEP "dbgprj", tTempDir()); + TEST_EQ(platformMkdirRecursive(sProjectDir), 0); + snprintf(sDbpPath, sizeof(sDbpPath), "%s" DVX_PATH_SEP DG_DBP, sProjectDir); + snprintf(sBasPath, sizeof(sBasPath), "%s" DVX_PATH_SEP DG_BAS, sProjectDir); + snprintf(sFrmPath, sizeof(sFrmPath), "%s" DVX_PATH_SEP DG_FRM, sProjectDir); + + const char *bas = basText ? basText : DG_BAS_TEXT; + const char *frm = frmText ? frmText : DG_FRM_TEXT; + bool moduleOnly = (basText != NULL && frmText == NULL); + const char *dbp = moduleOnly ? DG_DBP_NOFORM_TEXT : DG_DBP_TEXT; + + TEST_TRUE(tWriteFile(sDbpPath, dbp, (int32_t)strlen(dbp))); + TEST_TRUE(tWriteFile(sBasPath, bas, (int32_t)strlen(bas))); + + if (!moduleOnly) { + TEST_TRUE(tWriteFile(sFrmPath, frm, (int32_t)strlen(frm))); + } +} + + +void runDebugCases(void) { + caseDbgStepFlow(); + caseDbgWatchImm(); + caseDbgBpWindow(); + caseDbgErrors(); + caseDbgRunExtras(); + caseDbgRunHelp(); + dgPrepare(); +} diff --git a/src/test/ide/testIdeDesign.c b/src/test/ide/testIdeDesign.c new file mode 100644 index 0000000..aa6fb81 --- /dev/null +++ b/src/test/ide/testIdeDesign.c @@ -0,0 +1,2912 @@ +// 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. + + +// testIdeDesign.c -- T6 coverage cases: form designer, toolbox, +// properties grid, menu editor, project dialogs, find/replace modes, +// preferences and help topics. +// +// Compiled as part of testIde.c (see the include there); every helper +// and the IDE's file-scope state are directly visible. + +#define DC_CLICK_GAP_MS 1000 // clock gap so two clicks never form a double-click +#define DC_DBL_MS 60 // clock gap between the halves of a double-click +#define DC_EMPTY_X 260 // form-content point that hits no control +#define DC_EMPTY_Y 160 +#define DC_PLACE_X 40 // form-content point for placing controls +#define DC_PLACE_Y 20 +#define DC_DRAG_STEP 200 // resize drag distance in pixels +#define DC_BIG_SHRINK 500 // drag distance that underflows the min size +#define DC_TOOL_LABEL "Label" +#define DC_TOOL_FRAME "Frame" +#define DC_TOOL_BUTTON "CommandButton" +#define DC_TOOL_CHECKBOX "CheckBox" +#define DC_TOOL_TIMER "Timer" +#define DC_TOOL_DATA "Data" +#define DC_TOOL_TEXTBOX "TextBox" +#define DC_BAD_FRM "VERSION 5.00\nBegin Form Broken\nEnd\n" +#define DC_FORM2_FRM "form2.frm" +#define DC_BLANK_FRM "blank.frm" +#define DC_BLANK_BAS "blank.bas" +#define DC_MED_STEP_MAX 64 // menu editor script step cap +#define DM_ICON_SIZE 32 // valid project icon dimensions +#define DM_SMALL_ICON 16 // wrong-size icon dimensions +#define DM_BMP_HDR 54 // BMP file header bytes (24-bit, no palette) +#define DM_RECENT_FILL "/tmp/notreal%d.dbp" +#define DP_INT_VALUE 123 // spinner value for the MinWidth row +#define DP_TIMER_MS 500 // Interval value pushed through the grid +#define DP_FORM_POS 25 // form Left/Top test value +#define DP_FORM_W 320 // form Width test value +#define DP_FORM_H 240 // form Height test value +#define DP_TEST_R 16 // color slider test components +#define DP_TEST_G 32 +#define DP_TEST_B 48 +#define DP_TEST_COLOR 0x102030 +#define DC_MIN_CTRL_SIZE 8 // ideDesigner.c MIN_CTRL_SIZE (private to that file) + + +// Spy counters exported by ideStubs.c. +extern int32_t ideStubShellLoadCount; +extern char ideStubShellLoadPath[DVX_MAX_PATH]; + + +// Menu editor script state (per pass). +static int32_t sMedStep = 0; +static int32_t sMedErrors = 0; +static int32_t sMedPass = 0; +static int32_t sPpdStep = 0; +static int32_t sPpdErrors = 0; + + +static void caseDsgnBadAndMissingFiles(void); +static void caseDsgnCopyPaste(void); +static void caseDsgnDeleteTree(void); +static void caseDsgnFrmRoundTrip(void); +static void caseDsgnPlaceTools(void); +static void caseDsgnSelectResizeReorder(void); +static void caseFindScopes(void); +static void caseGapData(void); +static void caseGapMisc(void); +static void caseGapTbxIcons(void); +static void caseHelpQuery(void); +static void caseMenuEd(void); +static void casePrefsColors(void); +static void casePrjDialogs(void); +static void caseProcSplit(void); +static void casePropGrid(void); +static void casePropTree(void); +static void caseRecentAndRemove(void); +static void caseWinCmds(void); +static WidgetT *dcCtrlWidget(int32_t idx); +static void dcFormClick(int32_t x, int32_t y); +static void dcFormKey(int32_t key, int32_t mod); +static void dcFormMouse(int32_t x, int32_t y, bool down); +static void dcLayout(void); +static void dcPasteScript(AppContextT *ctx, WindowT *modal, void *userCtx); +static void dcPlace(const char *toolName, int32_t x, int32_t y); +static void dcPrepareDesign(void); +static void dcSelectCtrl(int32_t idx); +static bool dcToolClick(const char *toolName); +static void dmMenuScript(AppContextT *ctx, WindowT *modal, void *userCtx); +static void dmPpdScript(AppContextT *ctx, WindowT *modal, void *userCtx); +static void dmWriteBmp(const char *path, int32_t w, int32_t h); +static DsgnControlT *dpCtrlByName(const char *name); +static void dpDismissOk(WindowT *modal); +static void dpEditRow(const char *rowName, DialogPlanT *plan); +static WidgetT *dpFindNthByText(WidgetT *root, const char *text, int32_t nth); +static void dpFindRadio(const char *caption); +static int32_t dpLayoutIndex(const char *layoutName); +static void dpPrefsColors(WindowT *modal); +static void dpSelectByPtr(DsgnControlT *ctrl); +static void dpTreeAppend(WidgetT *parent, WidgetT *item); +static WidgetT *dpTreeFind(WidgetT *parent, const char *prefix); +static void dpTreeInsertFirst(WidgetT *parent, WidgetT *item); +void runDesignCases(void); + + +// Load a VB4-style .frm (parser refuses it), a .frm and a .bas that do +// not exist on disk: the designer must fall back to a blank form / an +// empty module instead of crashing or keeping stale state. +static void caseDsgnBadAndMissingFiles(void) { + tCase("bad .frm falls back to a blank form; missing files start empty"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + + char badPath[DVX_MAX_PATH]; + + snprintf(badPath, sizeof(badPath), "%s" DVX_PATH_SEP DC_FORM2_FRM, sProjectDir); + TEST_TRUE(tWriteFile(badPath, DC_BAD_FRM, (int32_t)strlen(DC_BAD_FRM))); + + ideStart(true); + openViaRecent(); + + // Unparseable form: dsgnLoadFrm fails, switchToDesign creates a + // default blank form so the designer still opens. + prjAddFile(&sProject, DC_FORM2_FRM, true); + prjRebuildTree(&sProject); + activateFile(sProject.fileCount - 1, ViewDesignE); + TEST_TRUE(sDesigner.form != NULL); + TEST_STR_EQ(sDesigner.form ? sDesigner.form->name : "", IDE_DEFAULT_FORM); + + // Form file missing on disk: blank form named after the file, and + // the entry is marked modified so Save writes it out. + prjAddFile(&sProject, DC_BLANK_FRM, true); + prjRebuildTree(&sProject); + activateFile(sProject.fileCount - 1, ViewDesignE); + TEST_TRUE(sDesigner.form != NULL); + TEST_STR_EQ(sDesigner.form ? sDesigner.form->name : "", "blank"); + TEST_TRUE(sProject.files[sProject.fileCount - 1].modified); + + // Module file missing on disk: empty editor, marked modified. + prjAddFile(&sProject, DC_BLANK_BAS, false); + prjRebuildTree(&sProject); + activateFile(sProject.fileCount - 1, ViewCodeE); + TEST_EQ(sEditorFileIdx, sProject.fileCount - 1); + TEST_STR_EQ(wgtGetText(sEditor), ""); + TEST_TRUE(sProject.files[sProject.fileCount - 1].modified); + + ideExit(); + tShutdown(); +} + + +// Clipboard copy/cut/paste on the design surface: plain paste renames, +// pasting over an existing name builds a control array (Yes) or renames +// (No), and non-control clipboard text is ignored. +static void caseDsgnCopyPaste(void) { + tCase("designer copy/cut/paste incl. control arrays"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + dcPrepareDesign(); + + // Enrich the fixture button so the emitted block carries every + // optional field the paste parser understands. + DsgnControlT *btn = sDesigner.form->controls[0]; + + btn->maxWidth = 150; + btn->maxHeight = 80; + btn->weight = 2; + snprintf(btn->helpTopic, DSGN_MAX_NAME, "%s", "topic.go"); + dsgnSetPropValue(btn, "Text", "GoText"); + + dcSelectCtrl(0); + dcFormKey(KEY_CTRL_C, ACCEL_CTRL); + + int32_t clipLen = 0; + const char *clip = dvxClipboardGet(&clipLen); + + TEST_TRUE(clip != NULL && strstr(clip, "Begin CommandButton " FIXTURE_BUTTON_NAME) != NULL); + TEST_TRUE(clip != NULL && strstr(clip, "MaxWidth = 150") != NULL); + TEST_TRUE(clip != NULL && strstr(clip, "Weight = 2") != NULL); + + // Plain paste: name collides with btnGo, script answers No -> the + // paste gets a fresh auto-generated name. + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_NO); + tDialogScript(dcPasteScript, &plan); + dcFormKey(KEY_CTRL_V, ACCEL_CTRL); + tPump(1); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 2); + TEST_STR_EQ(sDesigner.form->controls[1]->name, "Command1"); + TEST_EQ(sDesigner.form->controls[1]->maxWidth, 150); + TEST_EQ(sDesigner.form->controls[1]->weight, 2); + + // Paste again answering Yes: btnGo becomes a control array. + dialogPlanInit(&plan, BTN_YES); + tDialogScript(dcPasteScript, &plan); + dcFormKey(KEY_CTRL_V, ACCEL_CTRL); + tPump(1); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 3); + TEST_EQ(sDesigner.form->controls[0]->index, 0); + TEST_STR_EQ(sDesigner.form->controls[2]->name, FIXTURE_BUTTON_NAME); + TEST_EQ(sDesigner.form->controls[2]->index, 1); + + // Third paste: already an array, next index without a prompt. + dialogPlanInit(&plan, BTN_CANCEL); + tDialogScript(dcPasteScript, &plan); + dcFormKey(KEY_CTRL_V, ACCEL_CTRL); + tPump(1); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 4); + TEST_EQ(sDesigner.form->controls[3]->index, 2); + TEST_EQ(plan.prompts, 0); + tDialogScript(NULL, NULL); + + // Array members get an Index parameter in their event skeleton + // (an event with no existing handler, so one is generated). + navigateToNamedEventSub(FIXTURE_BUTTON_NAME, "DblClick"); + TEST_TRUE(sEditor != NULL); + TEST_TRUE(sEditor && strstr(wgtGetText(sEditor), "Sub " FIXTURE_BUTTON_NAME "_DblClick (Index As Integer") != NULL); + + // Cut removes the selected control and keeps it on the clipboard. + dcLayout(); + dcSelectCtrl(1); + TEST_STR_EQ(sDesigner.form->controls[1]->name, "Command1"); + dcFormKey(KEY_CTRL_X, ACCEL_CTRL); + tPump(1); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 3); + clip = dvxClipboardGet(&clipLen); + TEST_TRUE(clip != NULL && strstr(clip, "Begin CommandButton Command1") != NULL); + + // Non-control clipboard text: paste is a no-op. + dvxClipboardCopy("plain text", (int32_t)strlen("plain text")); + dcFormKey(KEY_CTRL_V, ACCEL_CTRL); + tPump(1); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 3); + + ideExit(); + tShutdown(); +} + + +// Delete on a container removes the container and every descendant, +// transitively; both the Del key on the form window and Edit / Delete. +static void caseDsgnDeleteTree(void) { + tCase("Delete removes a container with all descendants"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + dcPrepareDesign(); + + dcPlace(DC_TOOL_FRAME, DC_EMPTY_X, DC_PLACE_Y); + dcLayout(); + + WidgetT *frameWidget = dcCtrlWidget(1); + + TEST_TRUE(frameWidget != NULL); + + // Nested frame inside the first, then a label inside the nested one. + dcPlace(DC_TOOL_FRAME, frameWidget->x + 4, frameWidget->y + 4); + dcLayout(); + + WidgetT *innerWidget = dcCtrlWidget(2); + + TEST_TRUE(innerWidget != NULL); + TEST_STR_EQ(sDesigner.form->controls[2]->parentName, "Frame1"); + dcPlace(DC_TOOL_LABEL, innerWidget->x + 4, innerWidget->y + 4); + dcLayout(); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 4); + TEST_STR_EQ(sDesigner.form->controls[3]->parentName, "Frame2"); + + // Delete the outer frame: the whole subtree goes. Click inside + // its border (the centre would hit the nested children). + frameWidget = dcCtrlWidget(1); + TEST_TRUE(frameWidget != NULL); + dcFormClick(frameWidget->x + frameWidget->w - 3, frameWidget->y + frameWidget->h - 3); + TEST_EQ(sDesigner.selectedIdx, 1); + dcFormKey(KEY_DELETE, 0); + tPump(1); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 1); + TEST_EQ(sDesigner.selectedIdx, -1); + + // Edit / Delete removes the remaining fixture button. + dcLayout(); + dcSelectCtrl(0); + ideMenu(CMD_DELETE); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 0); + + ideExit(); + tShutdown(); +} + + +// Serialize a form carrying menus, arrays, interface properties and +// non-default flags; reload it from disk and verify every field +// survived the round trip. Exercises the preview menu bar and the +// designer menu click navigation as well. +static void caseDsgnFrmRoundTrip(void) { + tCase(".frm save/load round trip with menus and typed properties"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + dcPrepareDesign(); + + DsgnFormT *form = sDesigner.form; + + // Menu tree: File (with Open, separator, sub menu Recent > First + // [checked]), popup-only Context menu with a radio item, and a + // disabled top-level item. + DsgnMenuItemT mi; + + dsgnMenuItemInit(&mi); + snprintf(mi.caption, DSGN_MAX_TEXT, "%s", "&File"); + snprintf(mi.name, DSGN_MAX_NAME, "%s", "mnuFile"); + arrput(form->menuItems, mi); + dsgnMenuItemInit(&mi); + mi.level = 1; + snprintf(mi.caption, DSGN_MAX_TEXT, "%s", "&Open"); + snprintf(mi.name, DSGN_MAX_NAME, "%s", "mnuOpen"); + arrput(form->menuItems, mi); + dsgnMenuItemInit(&mi); + mi.level = 1; + snprintf(mi.caption, DSGN_MAX_TEXT, "%s", "-"); + snprintf(mi.name, DSGN_MAX_NAME, "%s", "mnuSep1"); + arrput(form->menuItems, mi); + dsgnMenuItemInit(&mi); + mi.level = 1; + snprintf(mi.caption, DSGN_MAX_TEXT, "%s", "&Recent"); + snprintf(mi.name, DSGN_MAX_NAME, "%s", "mnuRecent"); + arrput(form->menuItems, mi); + dsgnMenuItemInit(&mi); + mi.level = 2; + mi.checked = true; + snprintf(mi.caption, DSGN_MAX_TEXT, "%s", "First"); + snprintf(mi.name, DSGN_MAX_NAME, "%s", "mnuFirst"); + arrput(form->menuItems, mi); + dsgnMenuItemInit(&mi); + mi.visible = false; + snprintf(mi.caption, DSGN_MAX_TEXT, "%s", "Context"); + snprintf(mi.name, DSGN_MAX_NAME, "%s", "mnuContext"); + arrput(form->menuItems, mi); + dsgnMenuItemInit(&mi); + mi.level = 1; + mi.radioCheck = true; + mi.checked = true; + snprintf(mi.caption, DSGN_MAX_TEXT, "%s", "Mode"); + snprintf(mi.name, DSGN_MAX_NAME, "%s", "mnuMode"); + arrput(form->menuItems, mi); + dsgnMenuItemInit(&mi); + mi.enabled = false; + snprintf(mi.caption, DSGN_MAX_TEXT, "%s", "Off"); + snprintf(mi.name, DSGN_MAX_NAME, "%s", "mnuOff"); + arrput(form->menuItems, mi); + + // Controls: label with an enum interface property, checkbox with a + // bool interface property, timer with an int one; non-default + // built-in flags on the fixture button. + dcPlace(DC_TOOL_LABEL, DC_EMPTY_X, DC_PLACE_Y); + dcPlace(DC_TOOL_CHECKBOX, DC_EMPTY_X, DC_PLACE_Y); + dcPlace(DC_TOOL_TIMER, DC_EMPTY_X, DC_PLACE_Y); + + DsgnControlT *label = sDesigner.form->controls[1]; + DsgnControlT *check = sDesigner.form->controls[2]; + DsgnControlT *timer = sDesigner.form->controls[3]; + + dsgnSetPropValue(label, "Alignment", "Center"); + dsgnSetPropValue(check, "Value", "True"); + dsgnSetPropValue(timer, "Interval", "250"); + dsgnSetPropValue(label, "Custom", "raw value"); + + DsgnControlT *btn = sDesigner.form->controls[0]; + + btn->visible = false; + btn->enabled = false; + btn->left = 7; + btn->top = 9; + snprintf(btn->helpTopic, DSGN_MAX_NAME, "%s", "topic.btn"); + + // Form-level non-defaults. + form->centered = false; + form->left = 11; + form->top = 13; + snprintf(form->helpTopic, DSGN_MAX_NAME, "%s", "topic.form"); + + form->dirty = true; + sProject.files[FILE_FORM].modified = true; + ideMenu(CMD_SAVE); + TEST_TRUE(fileHas(sFrmPath, "Begin Menu mnuFile")); + TEST_TRUE(fileHas(sFrmPath, "Checked = True")); + TEST_TRUE(fileHas(sFrmPath, "RadioCheck = True")); + TEST_TRUE(fileHas(sFrmPath, "Enabled = False")); + TEST_TRUE(fileHas(sFrmPath, "Visible = False")); + TEST_TRUE(fileHas(sFrmPath, "Alignment = Center")); + TEST_TRUE(fileHas(sFrmPath, "Interval = 250")); + TEST_TRUE(fileHas(sFrmPath, "HelpTopic = \"topic.btn\"")); + TEST_TRUE(fileHas(sFrmPath, "Custom = \"raw value\"")); + TEST_TRUE(fileHas(sFrmPath, "Left = 11")); + + // Reload from disk and verify the parsed model. + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_NO); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_CLOSE); + tDialogScript(NULL, NULL); + TEST_TRUE(!hasProject()); + openViaRecent(); + treeOpenFile(FILE_FORM); + dcLayout(); + form = sDesigner.form; + TEST_EQ((int32_t)arrlen(form->menuItems), 8); + TEST_TRUE(form->menuItems[4].checked); + TEST_EQ(form->menuItems[4].level, 2); + TEST_TRUE(form->menuItems[6].radioCheck); + TEST_TRUE(!form->menuItems[7].enabled); + TEST_TRUE(!form->menuItems[5].visible); + TEST_EQ((int32_t)arrlen(form->controls), 4); + TEST_TRUE(!form->controls[0]->visible); + TEST_TRUE(!form->controls[0]->enabled); + TEST_EQ(form->controls[0]->left, 7); + TEST_STR_EQ(form->controls[0]->helpTopic, "topic.btn"); + TEST_STR_EQ(form->helpTopic, "topic.form"); + TEST_EQ(form->left, 11); + TEST_TRUE(!form->centered); + + const char *align = dsgnControlGetPropValue(form->controls[1], "Alignment"); + + TEST_STR_EQ(align ? align : "", "Center"); + + // The preview menu bar exists; clicking the deep item navigates to + // its Click handler skeleton. + TEST_TRUE(sFormWin != NULL && sFormWin->menuBar != NULL); + onFormWinMenu(sFormWin, DSGN_MENU_ID_BASE + 4); + tPump(1); + TEST_TRUE(sCodeWin != NULL); + TEST_TRUE(strstr(wgtGetText(sEditor), "Sub mnuFirst_Click") != NULL); + + // A non-designer menu id falls through to the shared handler. + onFormWinMenu(sFormWin, CMD_CLEAR); + + ideExit(); + tShutdown(); +} + + +// Place one control of several types through the toolbox, including a +// container child, layout-typed container content and auto-naming. +static void caseDsgnPlaceTools(void) { + tCase("toolbox placement: types, containers, auto names, tool toggle"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + dcPrepareDesign(); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 1); + + // Tool toggle: select then reselect deselects. + TEST_TRUE(dcToolClick(DC_TOOL_LABEL)); + TEST_STR_EQ(sDesigner.activeTool, DC_TOOL_LABEL); + TEST_TRUE(dcToolClick(DC_TOOL_LABEL)); + TEST_STR_EQ(sDesigner.activeTool, ""); + + // Crosshair cursor only while a tool is armed. + TEST_EQ(onFormWinCursorQuery(sFormWin, DC_PLACE_X, DC_PLACE_Y), 0); + TEST_TRUE(dcToolClick(DC_TOOL_LABEL)); + TEST_EQ(onFormWinCursorQuery(sFormWin, DC_PLACE_X, DC_PLACE_Y), CURSOR_CROSSHAIR); + TEST_TRUE(dcToolClick(DC_TOOL_LABEL)); + + dcPlace(DC_TOOL_LABEL, DC_EMPTY_X, DC_PLACE_Y); + TEST_EQ((int32_t)arrlen(sDesigner.form->controls), 2); + TEST_STR_EQ(sDesigner.form->controls[1]->name, "Label1"); + dcPlace(DC_TOOL_LABEL, DC_EMPTY_X, DC_PLACE_Y); + TEST_STR_EQ(sDesigner.form->controls[2]->name, "Label2"); + dcPlace(DC_TOOL_TEXTBOX, DC_EMPTY_X, DC_PLACE_Y); + TEST_STR_EQ(sDesigner.form->controls[3]->name, "TextBox1"); + dcPlace(DC_TOOL_DATA, DC_EMPTY_X, DC_PLACE_Y); + TEST_STR_EQ(sDesigner.form->controls[4]->name, "Data1"); + dcPlace(DC_TOOL_FRAME, DC_EMPTY_X, DC_PLACE_Y); + TEST_STR_EQ(sDesigner.form->controls[5]->name, "Frame1"); + dcLayout(); + + // Child dropped inside the frame nests there. + WidgetT *frameWidget = dcCtrlWidget(5); + + TEST_TRUE(frameWidget != NULL); + dcPlace(DC_TOOL_CHECKBOX, frameWidget->x + 4, frameWidget->y + 4); + TEST_STR_EQ(sDesigner.form->controls[6]->parentName, "Frame1"); + dcLayout(); + + // A frame whose Layout is HBox gets a tagged content box exactly + // once: place two children into it. + dsgnSetPropValue(sDesigner.form->controls[5], "Layout", "HBox"); + frameWidget = dcCtrlWidget(5); + TEST_TRUE(frameWidget != NULL); + dcPlace(DC_TOOL_LABEL, frameWidget->x + 4, frameWidget->y + 4); + dcLayout(); + frameWidget = dcCtrlWidget(5); + dcPlace(DC_TOOL_LABEL, frameWidget->x + 4, frameWidget->y + 4); + TEST_STR_EQ(sDesigner.form->controls[8]->parentName, "Frame1"); + dcLayout(); + + // The rebuilt hierarchy keeps children under their container. + dsgnRebuildWidgets(&sDesigner); + dcLayout(); + TEST_TRUE(dcCtrlWidget(6) != NULL); + + ideExit(); + tShutdown(); +} + + +// Selection, resize handles (E, S, SE, cursor shapes, minimum size) and +// drag-reorder of siblings on the canvas. +static void caseDsgnSelectResizeReorder(void) { + tCase("canvas select, resize handles and drag reorder"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + dcPrepareDesign(); + dcPlace(DC_TOOL_LABEL, DC_EMPTY_X, DC_PLACE_Y); + dcPlace(DC_TOOL_LABEL, DC_EMPTY_X, DC_PLACE_Y); + dcLayout(); + + // Click selects; empty area deselects. + dcSelectCtrl(0); + TEST_EQ(sDesigner.selectedIdx, 0); + dcFormClick(DC_EMPTY_X, DC_EMPTY_Y); + TEST_EQ(sDesigner.selectedIdx, -1); + dcSelectCtrl(0); + + WidgetT *w = dcCtrlWidget(0); + + TEST_TRUE(w != NULL); + + if (!w) { + ideExit(); + tShutdown(); + return; + } + + // Cursor shapes over the three handles. + TEST_EQ(onFormWinCursorQuery(sFormWin, w->x + w->w, w->y + w->h), CURSOR_RESIZE_DIAG_NWSE); + TEST_EQ(onFormWinCursorQuery(sFormWin, w->x + w->w, w->y + w->h / 2), CURSOR_RESIZE_H); + TEST_EQ(onFormWinCursorQuery(sFormWin, w->x + w->w / 2, w->y + w->h), CURSOR_RESIZE_V); + TEST_EQ(onFormWinCursorQuery(sFormWin, w->x + 1, w->y + 1), 0); + + // SE-handle drag grows both dimensions. The baseline is the + // laid-out widget size (the layout may stretch past the hint). + int32_t origW = w->w; + + tAdvanceClock(DC_CLICK_GAP_MS); + dcFormMouse(w->x + w->w, w->y + w->h, true); + TEST_EQ(sDesigner.mode, DSGN_RESIZING); + dcFormMouse(w->x + w->w + DC_DRAG_STEP, w->y + w->h + DC_DRAG_STEP, true); + dcFormMouse(w->x + w->w + DC_DRAG_STEP, w->y + w->h + DC_DRAG_STEP, false); + TEST_EQ(sDesigner.mode, DSGN_IDLE); + TEST_EQ(sDesigner.form->controls[0]->width, origW + DC_DRAG_STEP); + dcLayout(); + + // E handle: width only. Shrinking far below zero clamps to the + // minimum control size. + w = dcCtrlWidget(0); + tAdvanceClock(DC_CLICK_GAP_MS); + dcFormMouse(w->x + w->w, w->y + w->h / 2, true); + dcFormMouse(w->x + w->w - DC_BIG_SHRINK, w->y + w->h / 2, true); + dcFormMouse(w->x + w->w - DC_BIG_SHRINK, w->y + w->h / 2, false); + TEST_EQ(sDesigner.form->controls[0]->width, DC_MIN_CTRL_SIZE); + dcLayout(); + + // S handle: height only. + w = dcCtrlWidget(0); + + int32_t origH = w->h; + + tAdvanceClock(DC_CLICK_GAP_MS); + dcFormMouse(w->x + w->w / 2, w->y + w->h, true); + dcFormMouse(w->x + w->w / 2, w->y + w->h + DC_DRAG_STEP, true); + dcFormMouse(w->x + w->w / 2, w->y + w->h + DC_DRAG_STEP, false); + TEST_EQ(sDesigner.form->controls[0]->height, origH + DC_DRAG_STEP); + dcLayout(); + + // Drag-reorder: pull the button below the first label. The swap + // rebuilds every design widget, so only plain coordinates captured + // beforehand may be used after the move. + DsgnControlT *first = sDesigner.form->controls[0]; + WidgetT *below = dcCtrlWidget(1); + + w = dcCtrlWidget(0); + + int32_t dragX = w->x + w->w / 2; + int32_t downY = below->y + below->h; + + tAdvanceClock(DC_CLICK_GAP_MS); + dcFormMouse(dragX, w->y + w->h / 2, true); + TEST_EQ(sDesigner.mode, DSGN_REORDERING); + dcFormMouse(dragX, downY, true); + TEST_PTR_EQ(sDesigner.form->controls[1], first); + dcFormMouse(dragX, downY, false); + TEST_EQ(sDesigner.mode, DSGN_IDLE); + dcLayout(); + + // And back up past the midpoint of the control above. + WidgetT *above = dcCtrlWidget(0); + + w = dcCtrlWidget(1); + + int32_t upY = above->y; + + dragX = w->x + w->w / 2; + tAdvanceClock(DC_CLICK_GAP_MS); + dcFormMouse(dragX, w->y + w->h / 2, true); + dcFormMouse(dragX, upY, true); + TEST_PTR_EQ(sDesigner.form->controls[0], first); + dcFormMouse(dragX, upY, false); + dcLayout(); + + ideExit(); + tShutdown(); +} + + +// Find/Replace across every scope and direction plus the single +// Replace button, match case, and the reopened-dialog toggle. +static void caseFindScopes(void) { + tCase("find/replace scopes, directions, single replace, match case"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_FORM); + ideMenu(CMD_VIEW_CODE); + + ideMenu(CMD_FIND); + TEST_TRUE(sFindWin != NULL && sReplCheck != NULL); + TEST_TRUE(!wgtCheckboxIsChecked(sReplCheck)); + + // Reopening with Replace toggles the existing dialog. + ideMenu(CMD_REPLACE); + TEST_TRUE(wgtCheckboxIsChecked(sReplCheck)); + + // Backward project search from the form lands in main.bas. + wgtSetText(sFindInput, FIXTURE_GLOBAL_LINE); + dpFindRadio("Project"); + dpFindRadio("Backward"); + onFindNext(NULL); + tPump(1); + TEST_EQ(sEditorFileIdx, FILE_MAIN); + + // Function scope: no match in the (General) section for the + // handler-only text. + wgtSetText(sFindInput, FIXTURE_CALL_LINE); + dpFindRadio("Function"); + dpFindRadio("Forward"); + showProc(-1); + onFindNext(NULL); + tPump(1); + TEST_EQ(sEditorFileIdx, FILE_MAIN); + + // Match case: the lower-case needle misses, the exact one hits. + wgtCheckboxSetChecked(sCaseCheck, true); + dpFindRadio("Project"); + wgtSetText(sFindInput, "HELPER"); + onFindNext(NULL); + tPump(1); + wgtSetText(sFindInput, "Helper"); + onFindNext(NULL); + tPump(1); + TEST_TRUE(strstr(wgtGetText(sEditor), "Helper") != NULL); + wgtCheckboxSetChecked(sCaseCheck, false); + + // Single Replace: replace the found selection, then the fall-through + // to Find Next when nothing matching is selected. + wgtSetText(sFindInput, FIXTURE_GLOBAL_NAME); + wgtSetText(sReplInput, FIXTURE_GLOBAL_NAME); + wgtCheckboxSetChecked(sReplCheck, true); + onReplCheckChange(sReplCheck); + dpFindRadio("Function"); + showProc(0); + onFindNext(NULL); + onReplace(NULL); + onReplace(NULL); + tPump(1); + + // Replace All in Function scope. + showProc(0); + wgtSetText(sFindInput, FIXTURE_GLOBAL_NAME); + wgtSetText(sReplInput, "tally"); + onReplaceAll(NULL); + TEST_TRUE(strstr(wgtGetText(sEditor), "tally") != NULL); + + // Replace All in File scope (undo the rename in this file). + wgtSetText(sFindInput, "tally"); + wgtSetText(sReplInput, FIXTURE_GLOBAL_NAME); + dpFindRadio("File"); + onReplaceAll(NULL); + TEST_TRUE(strstr(getFullSource(), FIXTURE_GLOBAL_NAME) != NULL); + TEST_PTR_EQ(strstr(getFullSource(), "tally"), NULL); + + // Replace All in Object scope: only btnGo procedures change. + treeOpenFile(FILE_FORM); + ideMenu(CMD_VIEW_CODE); + updateDropdowns(); + + int32_t objIdx = -1; + + for (int32_t i = 0; i < (int32_t)arrlen(sObjItems); i++) { + if (strcasecmp(sObjItems[i], FIXTURE_BUTTON_NAME) == 0) { + objIdx = i; + } + } + + TEST_TRUE(objIdx >= 0); + sDropdownNavSuppressed = true; + wgtDropdownSetSelected(sObjDropdown, objIdx); + sDropdownNavSuppressed = false; + wgtSetText(sFindInput, FIXTURE_OUTPUT_TEXT); + wgtSetText(sReplInput, "tapped"); + dpFindRadio("Object"); + onReplaceAll(NULL); + TEST_TRUE(strstr(getFullSource(), "tapped") != NULL); + + // Close button destroys the dialog. + onFindCloseBtn(NULL); + TEST_PTR_EQ(sFindWin, NULL); + tPump(SETTLE_FRAMES); + + ideExit(); + tShutdown(); +} + + +// Data-binding editors backed by the stub SQL engine: table and column +// dropdowns, the (none) entry, preselection and cancel. +static void caseGapData(void) { + tCase("data binding editors with a working (stub) SQL engine"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + dcPrepareDesign(); + dcPlace(DC_TOOL_TEXTBOX, DC_EMPTY_X, DC_PLACE_Y); + dcPlace(DC_TOOL_DATA, DC_EMPTY_X, DC_PLACE_Y); + dcLayout(); + + DsgnControlT *textBox = sDesigner.form->controls[1]; + DsgnControlT *data = sDesigner.form->controls[2]; + + dsgnSetPropValue(data, "DatabaseName", "stub.db"); + dsgnSetPropValue(data, "RecordSource", "tbl"); + dsgnSetPropValue(textBox, "DataSource", "Data1"); + dsgnSetPropValue(textBox, "DataField", "colB"); + + DialogPlanT plan; + + // RecordSource table dropdown: pick the first table. + dpSelectByPtr(data); + dialogPlanInit(&plan, BTN_NO); + plan.choiceTitle = "RecordSource"; + plan.choiceIndex = 1; + dpEditRow("RecordSource", &plan); + + const char *rec = dsgnControlGetPropValue(data, "RecordSource"); + + TEST_STR_EQ(rec ? rec : "", "tblA"); + + // Cancelled choice leaves it alone. + dialogPlanInit(&plan, BTN_NO); + plan.choiceTitle = "RecordSource"; + plan.choiceIndex = -1; + dpEditRow("RecordSource", &plan); + rec = dsgnControlGetPropValue(data, "RecordSource"); + TEST_STR_EQ(rec ? rec : "", "tblA"); + + // (none) clears it. + dialogPlanInit(&plan, BTN_NO); + plan.choiceTitle = "RecordSource"; + plan.choiceIndex = 0; + dpEditRow("RecordSource", &plan); + rec = dsgnControlGetPropValue(data, "RecordSource"); + TEST_STR_EQ(rec ? rec : "", ""); + dsgnSetPropValue(data, "RecordSource", "tblA"); + prpRefresh(&sDesigner); + tPump(1); + + // DataField column dropdown on the bound TextBox; the current value + // colB preselects, we pick colA. + dpSelectByPtr(textBox); + dialogPlanInit(&plan, BTN_NO); + plan.choiceTitle = "DataField"; + plan.choiceIndex = 1; + dpEditRow("DataField", &plan); + + const char *fld = dsgnControlGetPropValue(textBox, "DataField"); + + TEST_STR_EQ(fld ? fld : "", "colA"); + + // A RecordSource that is already a SELECT keeps its own query. + dsgnSetPropValue(data, "RecordSource", "SELECT colA FROM tblA"); + dialogPlanInit(&plan, BTN_NO); + plan.choiceTitle = "DataField"; + plan.choiceIndex = 2; + dpEditRow("DataField", &plan); + fld = dsgnControlGetPropValue(textBox, "DataField"); + TEST_STR_EQ(fld ? fld : "", "colB"); + + // KeyColumn on the Data control itself. + dpSelectByPtr(data); + dialogPlanInit(&plan, BTN_NO); + plan.choiceTitle = "KeyColumn"; + plan.choiceIndex = 1; + dpEditRow("KeyColumn", &plan); + + const char *key = dsgnControlGetPropValue(data, "KeyColumn"); + + TEST_STR_EQ(key ? key : "", "colA"); + + ideExit(); + tShutdown(); +} + + +// Odds and ends: statuses without a project, compile failures for +// missing and oversized files, renames that reload buffers from disk, +// immediate-window overflow, double-click navigation on the canvas, +// window-close callbacks and in-file backward searches. +static void caseGapMisc(void) { + tCase("no-project statuses and tool windows"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(false); + + // Run/compile with nothing open. + ideMenu(CMD_RUN); + TEST_TRUE(strstr(wgtGetText(sStatus), "No source code") != NULL); + ideMenu(CMD_TOGGLE_BP); + + // Find opens even without a project when invoked via Find Next + // with no remembered search text (sFindText persists per process). + sFindText[0] = '\0'; + ideMenu(CMD_FIND_NEXT); + TEST_TRUE(sFindWin != NULL); + + if (sFindWin) { + sFindWin->onClose(sFindWin); + } + + tPump(SETTLE_FRAMES); + TEST_PTR_EQ(sFindWin, NULL); + + // Toolbox and Properties from the Window menu with no form. + ideMenu(CMD_WIN_TOOLBOX); + TEST_TRUE(sToolboxWin != NULL); + ideMenu(CMD_WIN_PROPS); + TEST_TRUE(sPropsWin != NULL); + sToolboxWin->onClose(sToolboxWin); + sPropsWin->onClose(sPropsWin); + tPump(1); + TEST_TRUE(sToolboxWin && !sToolboxWin->visible); + TEST_TRUE(sPropsWin && !sPropsWin->visible); + + // Content-focus tracking and edit routing with no editor target. + sOutWin->onFocus(sOutWin); + ideMenu(CMD_COPY); + sLastFocusWin = NULL; + ideMenu(CMD_COPY); + + ideExit(); + tShutdown(); + + tCase("compile failures: unreadable and oversized project files"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + + // A listed file that exists nowhere: compile refuses. + prjAddFile(&sProject, "ghost.bas", false); + runScript(NULL, CMD_RUN); + TEST_TRUE(strstr(sOutputBuf, "Cannot read ghost.bas") != NULL); + prjRemoveFile(&sProject, sProject.fileCount - 1); + + // A file pushing the concatenated source over the cap. + char bigPath[DVX_MAX_PATH]; + + snprintf(bigPath, sizeof(bigPath), "%s" DVX_PATH_SEP "big.bas", sProjectDir); + + int32_t bigLen = IDE_MAX_SOURCE - INSERTED_LINES * 8; + char *big = (char *)malloc((size_t)bigLen + 1); + + TEST_TRUE(big != NULL); + + if (big) { + for (int32_t i = 0; i < bigLen; i++) { + big[i] = (i % 8 == 7) ? '\n' : "' filler"[i % 8]; + } + + big[bigLen] = '\0'; + TEST_TRUE(tWriteFile(bigPath, big, bigLen)); + free(big); + } + + prjAddFile(&sProject, "big.bas", false); + runScript(NULL, CMD_RUN); + TEST_TRUE(strstr(sOutputBuf, "exceeds") != NULL); + prjRemoveFile(&sProject, sProject.fileCount - 1); + + ideExit(); + tShutdown(); + + tCase("rename reloads unloaded buffers from disk"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + + // Reference the button from main.bas, save, then drop the buffer so + // the rename has to reload it from disk. + treeOpenFile(FILE_MAIN); + showProc(-1); + editorFocus(); + wgtTextAreaCursorToEnd(sEditor); + tType("btnGo.Caption = \"z\""); + ideMenu(CMD_SAVE); + + // Open the form (main.bas buffer stays stashed), then null it out. + treeOpenFile(FILE_FORM); + dcLayout(); + free(sProject.files[FILE_MAIN].buffer); + sProject.files[FILE_MAIN].buffer = NULL; + + renameViaProperties(FIXTURE_BUTTON_NAME, FIXTURE_RENAMED); + TEST_TRUE(sProject.files[FILE_MAIN].buffer != NULL); + TEST_TRUE(sProject.files[FILE_MAIN].buffer && strstr(sProject.files[FILE_MAIN].buffer, FIXTURE_RENAMED ".Caption") != NULL); + TEST_TRUE(sProject.files[FILE_MAIN].modified); + + // Rename while the editor's General section holds a reference. + treeOpenFile(FILE_MAIN); + showProc(-1); + TEST_TRUE(strstr(wgtGetText(sEditor), FIXTURE_RENAMED ".Caption") != NULL); + treeOpenFile(FILE_FORM); + ideMenu(CMD_VIEW_CODE); + renameViaProperties(FIXTURE_RENAMED, FIXTURE_BUTTON_NAME); + + ideExit(); + tShutdown(); + + tCase("immediate overflow, canvas double-click, backward find"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + + // Immediate-window buffer overflow drops the oldest lines. + TEST_TRUE(sImmediate != NULL); + + char chunk[LINE_BUF]; + + memset(chunk, 'a', sizeof(chunk) - 1); + chunk[sizeof(chunk) - 1] = '\0'; + + for (int32_t i = 0; i < 8; i++) { + immPrintCallback(NULL, chunk, true); + } + + immPrintCallback(NULL, "tail", true); + TEST_TRUE(strstr(wgtGetText(sImmediate), "tail") != NULL); + + // Double-click the fixture button on the canvas: the code editor + // opens on its Click handler. + treeOpenFile(FILE_FORM); + dcLayout(); + + WidgetT *btn = dcCtrlWidget(0); + + TEST_TRUE(btn != NULL); + + if (btn) { + int32_t cx = btn->x + btn->w / 2; + int32_t cy = btn->y + btn->h / 2; + + tAdvanceClock(DC_CLICK_GAP_MS); + dcFormMouse(cx, cy, true); + dcFormMouse(cx, cy, false); + tAdvanceClock(DC_DBL_MS); + dcFormMouse(cx, cy, true); + dcFormMouse(cx, cy, false); + tPump(1); + TEST_TRUE(sCodeWin != NULL); + TEST_TRUE(strstr(wgtGetText(sEditor), FIXTURE_HANDLER) != NULL); + } + + // Close the designer window through its own close callback. + TEST_TRUE(sFormWin != NULL && sFormWin->onClose != NULL); + sFormWin->onClose(sFormWin); + tPump(SETTLE_FRAMES); + TEST_PTR_EQ(sFormWin, NULL); + + // In-file backward/forward searches across procedures: add a second + // procedure so every scan direction has something to skip. + treeOpenFile(FILE_MAIN); + showProc(-1); + + char withZeta[LINE_BUF * 4]; + + snprintf(withZeta, sizeof(withZeta), "%s\nSub Zeta\n counter = 9\nEnd Sub\n", wgtGetText(sEditor)); + wgtSetText(sEditor, withZeta); + onEditorChange(sEditor); + getFullSource(); + TEST_EQ((int32_t)arrlen(sProcBufs), 2); + + ideMenu(CMD_FIND); + TEST_TRUE(sFindWin != NULL); + + // Backward from General: the trailing procs are scanned descending. + showProc(-1); + wgtSetText(sFindInput, FIXTURE_HELPER_LINE); + dpFindRadio("Backward"); + onFindNext(NULL); + tPump(1); + TEST_EQ(sCurProcIdx, 0); + + // Backward from Helper: the General section is scanned. + wgtSetText(sFindInput, "Dim Shared"); + onFindNext(NULL); + tPump(1); + TEST_EQ(sCurProcIdx, -1); + + // Forward from General: procs after it hit first. + dpFindRadio("Forward"); + wgtSetText(sFindInput, "counter = 9"); + onFindNext(NULL); + tPump(1); + TEST_EQ(sCurProcIdx, 1); + + // Forward from Zeta: wrap to the proc before it. + wgtSetText(sFindInput, FIXTURE_HELPER_LINE); + onFindNext(NULL); + tPump(1); + TEST_EQ(sCurProcIdx, 0); + + // Forward from Helper into General. + wgtSetText(sFindInput, "Dim Shared"); + onFindNext(NULL); + tPump(1); + TEST_EQ(sCurProcIdx, -1); + + ideExit(); + tShutdown(); +} + + +// Toolbox icons: a widget with a resource-carrying .wgt path gets an +// image button and its resource name as tooltip; a second interface +// sharing the path probes the suffixed resource names. +static void caseGapTbxIcons(void) { + tCase("toolbox loads icons from .wgt resources"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + + // Build a fake .wgt next to the test binary (it must outlive the + // per-case temp dir because iface paths persist process-wide). + char cwd[DVX_MAX_PATH]; + char wgtPath[DVX_MAX_PATH]; + char bmpPath[DVX_MAX_PATH]; + + TEST_TRUE(getcwd(cwd, sizeof(cwd)) != NULL); + snprintf(wgtPath, sizeof(wgtPath), "%s" DVX_PATH_SEP "btnicon.wgt", cwd); + snprintf(bmpPath, sizeof(bmpPath), "%s" DVX_PATH_SEP "icon24.bmp", cwd); + + struct stat st; + + if (stat(wgtPath, &st) != 0) { + TEST_TRUE(tWriteFile(wgtPath, "fake wgt", (int32_t)strlen("fake wgt"))); + dmWriteBmp(bmpPath, DM_ICON_SIZE, DM_ICON_SIZE); + + int32_t bmpLen = 0; + char *bmp = platformReadFile(bmpPath, &bmpLen); + + TEST_TRUE(bmp != NULL); + + if (bmp) { + TEST_EQ(dvxResAppend(wgtPath, "icon24", DVX_RES_ICON, bmp, (uint32_t)bmpLen), 0); + free(bmp); + } + + TEST_EQ(dvxResAppend(wgtPath, "name", DVX_RES_TEXT, "Button Tool", (uint32_t)strlen("Button Tool") + 1), 0); + } + + wgtIfaceSetPath("button", wgtPath); + wgtIfaceSetPath("checkbox", wgtPath); + + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_FORM); + dcLayout(); + TEST_TRUE(sToolboxWin != NULL); + + // The button tool now renders as an image button (no text), so the + // text lookup for its caption fails while CheckBox (suffixed + // resources missing) still falls back to a text button. + TEST_PTR_EQ(tFindByText(sToolboxWin->widgetRoot, DC_TOOL_BUTTON), NULL); + TEST_TRUE(tFindByText(sToolboxWin->widgetRoot, DC_TOOL_CHECKBOX) != NULL); + + ideExit(); + tShutdown(); +} + + +// F1 context topics for every IDE window kind. +static void caseHelpQuery(void) { + tCase("context help topics per focused window"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_FORM); + dcLayout(); + + // Designer with the fixture button selected, then with none. + sDesigner.selectedIdx = 0; + dvxRaiseWindow(sAc, sFormWin); + tPump(1); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ctrl.commandbutton"); + sDesigner.selectedIdx = -1; + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ctrl.form"); + + // Toolbox with and without an armed tool. + dvxRaiseWindow(sAc, sToolboxWin); + tPump(1); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ide.toolbox"); + snprintf(sDesigner.activeTool, DSGN_MAX_NAME, "%s", DC_TOOL_LABEL); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ctrl.label"); + sDesigner.activeTool[0] = '\0'; + + // Properties panel: selected control topic, else the common topic. + dvxRaiseWindow(sAc, sPropsWin); + tPump(1); + sDesigner.selectedIdx = 0; + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ctrl.commandbutton"); + sDesigner.selectedIdx = -1; + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ctrl.common.props"); + + // Code editor: keyword under the cursor, else the editor topic. + ideMenu(CMD_VIEW_CODE); + showProc(0); + dvxRaiseWindow(sAc, sCodeWin); + tPump(1); + + editorFocus(); + wgtTextAreaCursorToEnd(sEditor); + tType("Print"); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "lang.io"); + tKeyEnter(); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ide.editor"); + + // Immediate, output, project and the debug windows. + dvxRaiseWindow(sAc, sImmWin); + tPump(1); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ide.immediate"); + dvxRaiseWindow(sAc, sOutWin); + tPump(1); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ide.output"); + dvxRaiseWindow(sAc, sProjectWin); + tPump(1); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ide.project"); + ideMenu(CMD_WIN_LOCALS); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ide.debug.locals"); + ideMenu(CMD_WIN_CALLSTACK); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ide.debug.callstack"); + ideMenu(CMD_WIN_WATCH); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ide.debug.watch"); + ideMenu(CMD_WIN_BREAKPOINTS); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ide.debug.breakpoints"); + + // Main window falls back to the overview. + dvxRaiseWindow(sAc, sWin); + tPump(1); + helpQueryHandler(NULL); + TEST_STR_EQ(sDxe.helpTopic, "ide.overview"); + + // Help menu entries launch the viewer through the shell. + int32_t loads = ideStubShellLoadCount; + + ideMenu(CMD_HELP_CONTENTS); + TEST_EQ(ideStubShellLoadCount, loads + 1); + ideMenu(CMD_HELP_API); + TEST_EQ(ideStubShellLoadCount, loads + 2); + + // About box. + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_OK); + plan.custom = dpDismissOk; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_HELP_ABOUT); + tDialogScript(NULL, NULL); + + ideExit(); + tShutdown(); +} + + +// Drive the Menu Editor dialog: build a small menu tree with every +// button, hit each validation error, accept, then rename an item that +// has code (the handler is rewritten), then the Cancel path. +static void caseMenuEd(void) { + tCase("menu editor: build, validate, accept, rename, cancel"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + dcPrepareDesign(); + + // Pass 1: full build with validation errors before the final OK. + sMedStep = 0; + sMedErrors = 0; + sMedPass = 0; + tDialogScript(dmMenuScript, NULL); + ideMenu(CMD_MENU_EDITOR); + tDialogScript(NULL, NULL); + TEST_EQ(sMedErrors, 2); + TEST_TRUE((int32_t)arrlen(sDesigner.form->menuItems) >= 4); + TEST_TRUE(sFormWin != NULL && sFormWin->menuBar != NULL); + + DsgnMenuItemT *found = NULL; + + for (int32_t i = 0; i < (int32_t)arrlen(sDesigner.form->menuItems); i++) { + if (strcasecmp(sDesigner.form->menuItems[i].name, "mnuMain") == 0) { + found = &sDesigner.form->menuItems[i]; + } + } + + TEST_TRUE(found != NULL); + TEST_TRUE(found && !found->visible); + + // Give mnuOpen a handler with a body so it survives proc stashes. + navigateToNamedEventSub("mnuOpen", "Click"); + TEST_TRUE(sEditor != NULL); + editorFocus(); + wgtTextAreaGoToLine(sEditor, 2); + tType("counter = 1"); + getFullSource(); + + // Pass 2: rename mnuOpen -> mnuShow; the handler follows. + sMedStep = 0; + sMedPass = 1; + tDialogScript(dmMenuScript, NULL); + ideMenu(CMD_MENU_EDITOR); + tDialogScript(NULL, NULL); + TEST_TRUE(sDesigner.form->code && strstr(sDesigner.form->code, "mnuShow_Click") != NULL); + TEST_TRUE(!sDesigner.form->code || strstr(sDesigner.form->code, "mnuOpen_Click") == NULL); + + // Pass 3: Cancel leaves the menu untouched. + int32_t before = (int32_t)arrlen(sDesigner.form->menuItems); + + sMedStep = 0; + sMedPass = 2; + tDialogScript(dmMenuScript, NULL); + ideMenu(CMD_MENU_EDITOR); + tDialogScript(NULL, NULL); + TEST_EQ((int32_t)arrlen(sDesigner.form->menuItems), before); + + // Pass 4: delete every item, then Cancel (still untouched). + sMedStep = 0; + sMedPass = 3; + tDialogScript(dmMenuScript, NULL); + ideMenu(CMD_MENU_EDITOR); + tDialogScript(NULL, NULL); + TEST_EQ((int32_t)arrlen(sDesigner.form->menuItems), before); + + ideExit(); + tShutdown(); +} + + +// Preferences Colors tab: pick entries, move sliders, verify the value +// labels, swatch and persisted color, and see it applied to the editor. +static void casePrefsColors(void) { + tCase("preferences colors tab and editor apply"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_MAIN); + TEST_TRUE(sEditor != NULL); + + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_NO); + plan.custom = dpPrefsColors; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PREFERENCES); + tDialogScript(NULL, NULL); + + char key[IDE_PREF_KEY_BUF]; + + snprintf(key, sizeof(key), PREF_KEY_COLOR_FMT, (int)SYNTAX_STRING); + TEST_EQ(prefsGetInt(sPrefs, PREF_SEC_SYNTAX, key, 0), DP_TEST_COLOR); + + ideExit(); + tShutdown(); +} + + +// Project Properties dialog: every field, the icon browse flows +// (invalid file, wrong size, copy-into-project, overwrite), the help +// browse, OK, Cancel, and OK with a broken icon path. +static void casePrjDialogs(void) { + tCase("project properties dialog, new project, add file"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + + // Support files: a valid icon outside the project, an invalid image, + // a wrong-size image and a help file inside the project. + char outsideIcon[DVX_MAX_PATH]; + char notImg[DVX_MAX_PATH]; + char smallImg[DVX_MAX_PATH]; + char helpFile[DVX_MAX_PATH]; + + snprintf(outsideIcon, sizeof(outsideIcon), "%s" DVX_PATH_SEP "icon32.bmp", tTempDir()); + snprintf(notImg, sizeof(notImg), "%s" DVX_PATH_SEP "notimg.txt", sProjectDir); + snprintf(smallImg, sizeof(smallImg), "%s" DVX_PATH_SEP "small.bmp", sProjectDir); + snprintf(helpFile, sizeof(helpFile), "%s" DVX_PATH_SEP "readme.hlp", sProjectDir); + dmWriteBmp(outsideIcon, DM_ICON_SIZE, DM_ICON_SIZE); + dmWriteBmp(smallImg, DM_SMALL_ICON, DM_SMALL_ICON); + TEST_TRUE(tWriteFile(notImg, "not an image", (int32_t)strlen("not an image"))); + TEST_TRUE(tWriteFile(helpFile, "help", (int32_t)strlen("help"))); + + ideStart(true); + openViaRecent(); + + sPpdStep = 0; + sPpdErrors = 0; + tDialogScript(dmPpdScript, NULL); + ideMenu(CMD_PRJ_PROPS); + tDialogScript(NULL, NULL); + TEST_EQ(sPpdErrors, 2); + TEST_STR_EQ(sProject.author, "Anna"); + TEST_STR_EQ(sProject.publisher, "Pub"); + TEST_STR_EQ(sProject.version, "2.1"); + TEST_STR_EQ(sProject.copyright, "(c)"); + TEST_STR_EQ(sProject.iconPath, "icon32.bmp"); + TEST_STR_EQ(sProject.helpFile, "readme.hlp"); + TEST_STR_EQ(sProject.startupForm, "Form1"); + TEST_TRUE(sProject.optionExplicit); + + char copied[DVX_MAX_PATH]; + + snprintf(copied, sizeof(copied), "%s" DVX_PATH_SEP "icon32.bmp", sProjectDir); + + struct stat st; + + TEST_EQ(stat(copied, &st), 0); + + // Cancel pass: edits are discarded. + sPpdStep = 100; + tDialogScript(dmPpdScript, NULL); + ideMenu(CMD_PRJ_PROPS); + tDialogScript(NULL, NULL); + TEST_STR_EQ(sProject.author, "Anna"); + + // Window-close pass behaves like Cancel. + sPpdStep = 300; + tDialogScript(dmPpdScript, NULL); + ideMenu(CMD_PRJ_PROPS); + tDialogScript(NULL, NULL); + TEST_STR_EQ(sProject.author, "Anna"); + + // OK with the icon file deleted: rejected, then Cancel. + TEST_EQ(unlink(copied), 0); + sPpdStep = 200; + sPpdErrors = 0; + tDialogScript(dmPpdScript, NULL); + ideMenu(CMD_PRJ_PROPS); + tDialogScript(NULL, NULL); + TEST_TRUE(sPpdErrors >= 1); + + // New Project: empty name aborts; a real name runs the full flow. + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_NO); + plan.inputText = ""; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_NEW); + TEST_STR_EQ(sProject.name, FIXTURE_NAME); + + char p2dir[DVX_MAX_PATH]; + + snprintf(p2dir, sizeof(p2dir), "%s" DVX_PATH_SEP "p2", tTempDir()); + TEST_EQ(platformMkdirRecursive(p2dir), 0); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "Proj2"; + plan.fileTitle = "Save New Project"; + plan.fileDir = p2dir; + plan.fileName = "Proj2.dbp"; + plan.fileButton = BTN_SAVE; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_NEW); + tDialogScript(NULL, NULL); + TEST_STR_EQ(sProject.name, "Proj2"); + TEST_TRUE(hasProject()); + + // Add File: a fresh module inside the project directory is added; a + // file outside it is refused; an already-listed file activates. + char utilPath[DVX_MAX_PATH]; + char outPath[DVX_MAX_PATH]; + + snprintf(utilPath, sizeof(utilPath), "%s" DVX_PATH_SEP "util.bas", p2dir); + snprintf(outPath, sizeof(outPath), "%s" DVX_PATH_SEP "out.bas", tTempDir()); + TEST_TRUE(tWriteFile(utilPath, "x = 1\n", (int32_t)strlen("x = 1\n"))); + TEST_TRUE(tWriteFile(outPath, "y = 2\n", (int32_t)strlen("y = 2\n"))); + + dialogPlanInit(&plan, BTN_NO); + plan.fileDir = p2dir; + plan.fileName = "util.bas"; + plan.fileButton = BTN_OPEN; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_OPEN); + tDialogScript(NULL, NULL); + TEST_EQ(sProject.fileCount, 1); + TEST_STR_EQ(sProject.files[0].path, "util.bas"); + + dialogPlanInit(&plan, BTN_NO); + plan.fileDir = tTempDir(); + plan.fileName = "out.bas"; + plan.fileButton = BTN_OPEN; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_OPEN); + tDialogScript(NULL, NULL); + TEST_EQ(plan.errors, 1); + TEST_EQ(sProject.fileCount, 1); + + dialogPlanInit(&plan, BTN_NO); + plan.fileDir = p2dir; + plan.fileName = "util.bas"; + plan.fileButton = BTN_OPEN; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_OPEN); + tDialogScript(NULL, NULL); + TEST_EQ(sProject.fileCount, 1); + TEST_EQ(sProject.activeFileIdx, 0); + + // Implicit project from a bare .bas (with a matching .frm): close + // this project, then open the pair through Add File. + dialogPlanInit(&plan, BTN_NO); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_CLOSE); + tDialogScript(NULL, NULL); + TEST_TRUE(!hasProject()); + + char soloDir[DVX_MAX_PATH]; + char soloBas[DVX_MAX_PATH]; + char soloFrm[DVX_MAX_PATH]; + + snprintf(soloDir, sizeof(soloDir), "%s" DVX_PATH_SEP "solo", tTempDir()); + TEST_EQ(platformMkdirRecursive(soloDir), 0); + snprintf(soloBas, sizeof(soloBas), "%s" DVX_PATH_SEP "solo.bas", soloDir); + snprintf(soloFrm, sizeof(soloFrm), "%s" DVX_PATH_SEP "solo.frm", soloDir); + TEST_TRUE(tWriteFile(soloBas, "z = 3\n", (int32_t)strlen("z = 3\n"))); + TEST_TRUE(tWriteFile(soloFrm, "VERSION DVX 1.00\nBegin Form Solo\nEnd\n", (int32_t)strlen("VERSION DVX 1.00\nBegin Form Solo\nEnd\n"))); + + dialogPlanInit(&plan, BTN_NO); + plan.fileDir = soloDir; + plan.fileName = "solo.bas"; + plan.fileButton = BTN_OPEN; + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_OPEN); + tDialogScript(NULL, NULL); + TEST_TRUE(hasProject()); + TEST_STR_EQ(sProject.name, "solo"); + TEST_EQ(sProject.fileCount, 2); + + // Recent entry 0 is now solo.bas; reopening it goes through the + // single-path branch of recentOpen. + TEST_TRUE(arrlen(sRecentFiles) > 0 && strstr(sRecentFiles[0], "solo.bas") != NULL); + ideMenu(CMD_RECENT_BASE); + TEST_STR_EQ(sProject.name, "solo"); + + // A recent project that no longer exists reports an error. + dialogPlanInit(&plan, BTN_NO); + tDialogScript(dialogAnswer, &plan); + openProjectPath("/nonexistent/gone.dbp"); + tDialogScript(NULL, NULL); + TEST_EQ(plan.errors, 1); + + // prjFullPath bounds check. + char boundsBuf[DVX_MAX_PATH]; + + boundsBuf[0] = 'x'; + prjFullPath(&sProject, 99, boundsBuf, sizeof(boundsBuf)); + TEST_STR_EQ(boundsBuf, ""); + + ideExit(); + tShutdown(); +} + + +// New Sub/Function blocks typed into the General section or pasted into +// a procedure buffer are extracted into their own buffers; duplicates +// stay behind for the compiler to report; empty skeletons are dropped. +static void caseProcSplit(void) { + tCase("proc extraction, duplicates, empty skeleton discard, dropdowns"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_MAIN); + + // Type a new Sub into (General): it moves into its own buffer. + char newText[LINE_BUF * 4]; + + snprintf(newText, sizeof(newText), "%s\nSub Extra\n counter = 2\nEnd Sub\n", wgtGetText(sEditor)); + wgtSetText(sEditor, newText); + onEditorChange(sEditor); + getFullSource(); + TEST_EQ((int32_t)arrlen(sProcBufs), 2); + TEST_PTR_EQ(strstr(sGeneralBuf, "Sub Extra"), NULL); + TEST_TRUE(strstr(wgtGetText(sEditor), "Sub Extra") == NULL); + + // A duplicate of an existing proc stays in General. + snprintf(newText, sizeof(newText), "%s\nSub Helper\nEnd Sub\n", wgtGetText(sEditor)); + wgtSetText(sEditor, newText); + onEditorChange(sEditor); + getFullSource(); + TEST_EQ((int32_t)arrlen(sProcBufs), 2); + TEST_TRUE(strstr(sGeneralBuf, "Sub Helper") != NULL); + wgtSetText(sEditor, sGeneralBuf); + onEditorChange(sEditor); + + // Paste a second block into a proc buffer: the own proc stays, the + // new one is extracted, and a duplicate lands back in General. + showProc(0); + + char multi[LINE_BUF * 6]; + + snprintf(multi, sizeof(multi), "%s\nSub Another\n counter = 3\nEnd Sub\n\nSub Extra\n counter = 4\nEnd Sub\n", wgtGetText(sEditor)); + wgtSetText(sEditor, multi); + onEditorChange(sEditor); + getFullSource(); + TEST_EQ((int32_t)arrlen(sProcBufs), 3); + TEST_TRUE(strstr(sGeneralBuf, "Sub Extra") != NULL); + TEST_TRUE(strstr(wgtGetText(sEditor), "Sub Another") == NULL); + + // Empty skeleton discard through the Object/Event dropdowns: the + // form object with no implemented events auto-creates its default + // Load skeleton; switching away drops the empty body again. + treeOpenFile(FILE_FORM); + ideMenu(CMD_VIEW_CODE); + TEST_EQ((int32_t)arrlen(sProcBufs), 1); + + int32_t formIdx = -1; + + for (int32_t i = 0; i < (int32_t)arrlen(sObjItems); i++) { + if (strcasecmp(sObjItems[i], FIXTURE_NAME "Form") == 0 || strcasecmp(sObjItems[i], "Form1") == 0) { + formIdx = i; + } + } + + TEST_TRUE(formIdx >= 0); + wgtDropdownSetSelected(sObjDropdown, formIdx); + onObjDropdownChange(sObjDropdown); + TEST_EQ((int32_t)arrlen(sProcBufs), 2); + TEST_TRUE(strstr(wgtGetText(sEditor), "Sub Form1_Load") != NULL); + showProc(-1); + TEST_EQ((int32_t)arrlen(sProcBufs), 1); + + // A bracketed (unimplemented) event picked by hand also creates a + // skeleton; a custom Sub shows up unbracketed. + updateDropdowns(); + + int32_t btnIdx = -1; + + for (int32_t i = 0; i < (int32_t)arrlen(sObjItems); i++) { + if (strcasecmp(sObjItems[i], FIXTURE_BUTTON_NAME) == 0) { + btnIdx = i; + } + } + + TEST_TRUE(btnIdx >= 0); + sDropdownNavSuppressed = true; + wgtDropdownSetSelected(sObjDropdown, btnIdx); + onObjDropdownChange(sObjDropdown); + sDropdownNavSuppressed = false; + + int32_t bracketIdx = -1; + + for (int32_t i = 0; i < (int32_t)arrlen(sEvtItems); i++) { + if (sEvtItems[i][0] == '[') { + bracketIdx = i; + } + } + + TEST_TRUE(bracketIdx >= 0); + wgtDropdownSetSelected(sEvtDropdown, bracketIdx); + onEvtDropdownChange(sEvtDropdown); + TEST_EQ((int32_t)arrlen(sProcBufs), 2); + TEST_TRUE(strstr(wgtGetText(sEditor), "Sub " FIXTURE_BUTTON_NAME "_") != NULL); + showProc(0); + TEST_EQ((int32_t)arrlen(sProcBufs), 1); + + ideExit(); + tShutdown(); +} + + +// Every editable row type of the properties grid, on controls and on +// the form itself, including renames with every rejection reason. +static void casePropGrid(void) { + tCase("properties grid: int, bool, enum, string, data and layout rows"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + dcPrepareDesign(); + dcPlace(DC_TOOL_TEXTBOX, DC_EMPTY_X, DC_PLACE_Y); + dcPlace(DC_TOOL_LABEL, DC_EMPTY_X, DC_PLACE_Y); + dcPlace(DC_TOOL_TIMER, DC_EMPTY_X, DC_PLACE_Y); + dcPlace(DC_TOOL_FRAME, DC_EMPTY_X, DC_PLACE_Y); + dcLayout(); + + DsgnControlT *textBox = sDesigner.form->controls[1]; + DsgnControlT *label = sDesigner.form->controls[2]; + DsgnControlT *timer = sDesigner.form->controls[3]; + WidgetT *frameW = dcCtrlWidget(4); + + TEST_TRUE(frameW != NULL); + dcPlace(DC_TOOL_CHECKBOX, frameW->x + 4, frameW->y + 4); + dcLayout(); + + DsgnControlT *frame = sDesigner.form->controls[4]; + DsgnControlT *child = sDesigner.form->controls[5]; + + DialogPlanT plan; + + // Seed the data-binding rows the way a loaded .frm would. + dsgnSetPropValue(textBox, "DataSource", ""); + dsgnSetPropValue(textBox, "DataField", ""); + + // DataSource with no Data control on the form: refused silently. + dpSelectByPtr(textBox); + dialogPlanInit(&plan, BTN_NO); + dpEditRow("DataSource", &plan); + const char *seeded = dsgnControlGetPropValue(textBox, "DataSource"); + + TEST_STR_EQ(seeded ? seeded : "(missing)", ""); + + dcPlace(DC_TOOL_DATA, DC_EMPTY_X, DC_PLACE_Y); + dcLayout(); + + DsgnControlT *data = sDesigner.form->controls[6]; + + // Read-only rows do nothing. + dpSelectByPtr(textBox); + dialogPlanInit(&plan, BTN_NO); + dpEditRow("Type", &plan); + TEST_EQ(plan.lastModalId, -1); + + // Integer row through the spinner input box. + dialogPlanInit(&plan, BTN_NO); + plan.useSpinner = true; + plan.intValue = DP_INT_VALUE; + dpEditRow("MinWidth", &plan); + TEST_EQ(textBox->width, DP_INT_VALUE); + + // Bool rows toggle in place (no dialog): container Visible and + // Enabled cascade into children. + dpSelectByPtr(frame); + dialogPlanInit(&plan, BTN_NO); + dpEditRow("Visible", &plan); + TEST_TRUE(!frame->visible); + TEST_TRUE(child->widget && !child->widget->visible); + dpEditRow("Visible", &plan); + TEST_TRUE(frame->visible); + dpEditRow("Enabled", &plan); + TEST_TRUE(!frame->enabled); + TEST_TRUE(child->widget && !child->widget->enabled); + dpEditRow("Enabled", &plan); + + // Enum row cycles to the next value. + dpSelectByPtr(label); + dialogPlanInit(&plan, BTN_NO); + dpEditRow("Alignment", &plan); + + const char *align = dsgnControlGetPropValue(label, "Alignment"); + + TEST_STR_EQ(align ? align : "", "Center"); + + // Interface bool (Timer.Enabled) toggles; interface int + // (Timer.Interval) goes through the spinner and the widget setter. + dpSelectByPtr(timer); + dialogPlanInit(&plan, BTN_NO); + dpEditRow("Enabled", &plan); + dialogPlanInit(&plan, BTN_NO); + plan.useSpinner = true; + plan.intValue = DP_TIMER_MS; + dpEditRow("Interval", &plan); + + const char *interval = dsgnControlGetPropValue(timer, "Interval"); + + TEST_STR_EQ(interval ? interval : "", "500"); + + // Plain string rows: HelpTopic, Caption, and a custom key. + dpSelectByPtr(label); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "hp.label"; + dpEditRow("HelpTopic", &plan); + TEST_STR_EQ(label->helpTopic, "hp.label"); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "New Caption"; + dpEditRow("Caption", &plan); + TEST_TRUE(label->widget && strcmp(wgtGetText(label->widget), "New Caption") == 0); + dsgnSetPropValue(label, "Custom", "one"); + prpRefresh(&sDesigner); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "two"; + dpEditRow("Custom", &plan); + + const char *custom = dsgnControlGetPropValue(label, "Custom"); + + TEST_STR_EQ(custom ? custom : "", "two"); + + // Cancelled input box changes nothing. + dialogPlanInit(&plan, BTN_NO); + dpEditRow("HelpTopic", &plan); + TEST_STR_EQ(label->helpTopic, "hp.label"); + + // DataSource choice: pick the Data control, then clear via (none). + dpSelectByPtr(textBox); + dialogPlanInit(&plan, BTN_NO); + plan.choiceTitle = "DataSource"; + plan.choiceIndex = 1; + dpEditRow("DataSource", &plan); + + const char *src = dsgnControlGetPropValue(textBox, "DataSource"); + + TEST_STR_EQ(src ? src : "", "Data1"); + + // DataField: no database resolvable -> free-text fallback. + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "colA"; + dpEditRow("DataField", &plan); + + const char *fld = dsgnControlGetPropValue(textBox, "DataField"); + + TEST_STR_EQ(fld ? fld : "", "colA"); + + // With DatabaseName/RecordSource set the SQL probe runs (and fails + // through the stub SQL API), still falling back to free text. + dsgnSetPropValue(data, "DatabaseName", "test.db"); + dsgnSetPropValue(data, "RecordSource", "tbl"); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "colB"; + dpEditRow("DataField", &plan); + + // RecordSource and KeyColumn on the Data control itself; an + // absolute DatabaseName takes the other resolveDbPath branch. + dpSelectByPtr(data); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "orders"; + dpEditRow("RecordSource", &plan); + dsgnSetPropValue(data, "DatabaseName", "/abs/test.db"); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "id"; + dpEditRow("KeyColumn", &plan); + + // Layout on the frame container, driven by the choice dialog. + dpSelectByPtr(frame); + dialogPlanInit(&plan, BTN_NO); + plan.choiceTitle = "Layout"; + plan.choiceIndex = dpLayoutIndex("HBox"); + dpEditRow("Layout", &plan); + + const char *layout = dsgnControlGetPropValue(frame, "Layout"); + + TEST_STR_EQ(layout ? layout : "", "HBox"); + dcLayout(); + + // Renames: invalid ident, too long, in use, then a real rename of + // the container (children follow) and of the Data control + // (DataSource references follow). + dpSelectByPtr(frame); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "1bad"; + dpEditRow(PROP_ROW_NAME, &plan); + TEST_EQ(plan.errors, 1); + TEST_STR_EQ(frame->name, "Frame1"); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + dpEditRow(PROP_ROW_NAME, &plan); + TEST_EQ(plan.errors, 1); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = FIXTURE_BUTTON_NAME; + dpEditRow(PROP_ROW_NAME, &plan); + TEST_EQ(plan.errors, 1); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "Box1"; + dpEditRow(PROP_ROW_NAME, &plan); + TEST_EQ(plan.errors, 0); + TEST_STR_EQ(frame->name, "Box1"); + TEST_STR_EQ(child->parentName, "Box1"); + dpSelectByPtr(data); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "dataMain"; + dpEditRow(PROP_ROW_NAME, &plan); + + const char *ref = dsgnControlGetPropValue(textBox, "DataSource"); + + TEST_STR_EQ(ref ? ref : "", "dataMain"); + + // Form rows. + sDesigner.selectedIdx = -1; + prpRefresh(&sDesigner); + tPump(1); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "Fixture II"; + dpEditRow("Caption", &plan); + TEST_STR_EQ(sDesigner.form->caption, "Fixture II"); + dialogPlanInit(&plan, BTN_NO); + dpEditRow("AutoSize", &plan); + TEST_TRUE(sDesigner.form->autoSize); + dialogPlanInit(&plan, BTN_NO); + dpEditRow("Resizable", &plan); + TEST_TRUE(!sDesigner.form->resizable); + dialogPlanInit(&plan, BTN_NO); + dpEditRow("Centered", &plan); + TEST_TRUE(!sDesigner.form->centered); + dialogPlanInit(&plan, BTN_NO); + plan.useSpinner = true; + plan.intValue = DP_FORM_POS; + dpEditRow("Left", &plan); + TEST_EQ(sDesigner.form->left, DP_FORM_POS); + dialogPlanInit(&plan, BTN_NO); + plan.useSpinner = true; + plan.intValue = DP_FORM_POS; + dpEditRow("Top", &plan); + dialogPlanInit(&plan, BTN_NO); + plan.useSpinner = true; + plan.intValue = DP_FORM_W; + dpEditRow("Width", &plan); + TEST_TRUE(!sDesigner.form->autoSize); + dialogPlanInit(&plan, BTN_NO); + plan.useSpinner = true; + plan.intValue = DP_FORM_H; + dpEditRow("Height", &plan); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "hp.form"; + dpEditRow("HelpTopic", &plan); + TEST_STR_EQ(sDesigner.form->helpTopic, "hp.form"); + dialogPlanInit(&plan, BTN_NO); + plan.inputText = "MainForm"; + dpEditRow(PROP_ROW_NAME, &plan); + TEST_STR_EQ(sDesigner.form->name, "MainForm"); + + // Form Layout row through the choice dialog. + dialogPlanInit(&plan, BTN_NO); + plan.choiceTitle = "Layout"; + plan.choiceIndex = dpLayoutIndex("HBox"); + dpEditRow("Layout", &plan); + TEST_STR_EQ(sDesigner.form->layout, "HBox"); + dcLayout(); + + ideExit(); + tShutdown(); +} + + +// Tree pane of the Properties window: selection clicks, a valid +// drag-reorder (including a reparent into a container) and the revert +// when something is dropped into a non-container. +static void casePropTree(void) { + tCase("properties tree: select, reorder, reparent, invalid revert"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + dcPrepareDesign(); + dcPlace(DC_TOOL_FRAME, DC_EMPTY_X, DC_PLACE_Y); + dcPlace(DC_TOOL_LABEL, DC_EMPTY_X, DC_PLACE_Y); + dcLayout(); + + WidgetT *tree = findOfClass(sPropsWin->widgetRoot, sTreeViewClass, 0); + + TEST_TRUE(tree != NULL && tree->firstChild != NULL); + + if (!tree || !tree->firstChild) { + ideExit(); + tShutdown(); + return; + } + + WidgetT *formItem = tree->firstChild; + WidgetT *btnItem = dpTreeFind(formItem, FIXTURE_BUTTON_NAME); + WidgetT *frameItem = dpTreeFind(formItem, "Frame1"); + WidgetT *labelItem = dpTreeFind(formItem, "Label1"); + + TEST_TRUE(btnItem != NULL && frameItem != NULL && labelItem != NULL); + + // Item clicks drive the canvas selection. + labelItem->onClick(labelItem); + TEST_EQ(sDesigner.selectedIdx, 2); + formItem->onClick(formItem); + TEST_EQ(sDesigner.selectedIdx, -1); + + // Sibling reorder: move the label above the button. + widgetRemoveChild(formItem, labelItem); + dpTreeInsertFirst(formItem, labelItem); + tree->onChange(tree); + tPump(1); + TEST_STR_EQ(sDesigner.form->controls[0]->name, "Label1"); + TEST_STR_EQ(sDesigner.form->controls[1]->name, FIXTURE_BUTTON_NAME); + + // Reparent the button into the frame container. + tree = findOfClass(sPropsWin->widgetRoot, sTreeViewClass, 0); + formItem = tree->firstChild; + btnItem = dpTreeFind(formItem, FIXTURE_BUTTON_NAME); + frameItem = dpTreeFind(formItem, "Frame1"); + TEST_TRUE(btnItem != NULL && frameItem != NULL); + widgetRemoveChild(formItem, btnItem); + dpTreeAppend(frameItem, btnItem); + tree->onChange(tree); + tPump(1); + TEST_STR_EQ(dpCtrlByName(FIXTURE_BUTTON_NAME)->parentName, "Frame1"); + + // Dropping into a non-container reverts the whole edit. + tree = findOfClass(sPropsWin->widgetRoot, sTreeViewClass, 0); + formItem = tree->firstChild; + btnItem = dpTreeFind(formItem, FIXTURE_BUTTON_NAME); + labelItem = dpTreeFind(formItem, "Label1"); + TEST_TRUE(btnItem != NULL && labelItem != NULL); + widgetRemoveChild(btnItem->parent, btnItem); + dpTreeAppend(labelItem, btnItem); + tree->onChange(tree); + tPump(1); + TEST_STR_EQ(dpCtrlByName(FIXTURE_BUTTON_NAME)->parentName, "Frame1"); + + ideExit(); + tShutdown(); +} + + +// Recent-list cap and reload, plus Remove File edge paths (cancel, +// prompt-cancel, active form removal, breakpoint shifting). +static void caseRecentAndRemove(void) { + tCase("recent list cap/reload; remove-file edge paths"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + + // Seed a full recent list: the fixture first, then fillers. + char configDir[DVX_MAX_PATH]; + char prefsPath[DVX_MAX_PATH]; + + snprintf(configDir, sizeof(configDir), "%s" DVX_PATH_SEP CONFIG_DIR_NAME, tTempDir()); + TEST_EQ(platformMkdirRecursive(configDir), 0); + snprintf(prefsPath, sizeof(prefsPath), "%s" DVX_PATH_SEP IDE_PREFS_FILE, configDir); + + PrefsHandleT *h = prefsCreate(); + + for (int32_t i = 0; i < CMD_RECENT_MAX; i++) { + char key[IDE_PREF_KEY_BUF]; + char val[DVX_MAX_PATH]; + + snprintf(key, sizeof(key), PREF_KEY_RECENT_FMT, (long)i); + + if (i == 0) { + snprintf(val, sizeof(val), "%s", sDbpPath); + } else { + snprintf(val, sizeof(val), DM_RECENT_FILL, (int)i); + } + + prefsSetString(h, PREF_SEC_RECENT, key, val); + } + + TEST_TRUE(prefsSaveAs(h, prefsPath)); + prefsClose(h); + + ideStart(false); + TEST_EQ((int32_t)arrlen(sRecentFiles), CMD_RECENT_MAX); + + // recentLoad with a full list first frees the old entries. + recentLoad(); + TEST_EQ((int32_t)arrlen(sRecentFiles), CMD_RECENT_MAX); + + // Opening the fixture moves it to the top (existing-entry branch). + ideMenu(CMD_RECENT_BASE); + tPump(SETTLE_FRAMES); + TEST_TRUE(hasProject()); + TEST_STR_EQ(sProject.name, FIXTURE_NAME); + TEST_TRUE(strstr(sRecentFiles[0], FIXTURE_DBP) != NULL); + + // A brand-new path drops the oldest entry to stay at the cap. + recentAdd("/tmp/other.dbp"); + TEST_EQ((int32_t)arrlen(sRecentFiles), CMD_RECENT_MAX); + TEST_TRUE(strstr(sRecentFiles[0], "other.dbp") != NULL); + + // Breakpoints in both files; removing main.bas drops its + // breakpoint and shifts the form one down. + treeOpenFile(FILE_MAIN); + + int32_t globalLine = lineOf(wgtGetText(sEditor), FIXTURE_GLOBAL_LINE); + + toggleBreakpointLine(globalLine); + treeOpenFile(FILE_FORM); + ideMenu(CMD_VIEW_CODE); + showProc(0); + + int32_t callLine = lineOf(wgtGetText(sEditor), FIXTURE_CALL_LINE); + + toggleBreakpointLine(callLine); + TEST_EQ((int32_t)arrlen(sBreakpoints), 2); + + // Remove with the confirm answered No: nothing happens. + treeSelectFile(FILE_MAIN); + + DialogPlanT plan; + + dialogPlanInit(&plan, BTN_NO); + tDialogScript(dialogAnswer, &plan); + onMenu(sWin, CMD_PRJ_REMOVE); + tDialogScript(NULL, NULL); + tPump(1); + TEST_EQ(sProject.fileCount, 2); + + // Remove main.bas for real (clean file: no save prompt). + dialogPlanInit(&plan, BTN_YES); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_REMOVE); + tDialogScript(NULL, NULL); + TEST_EQ(sProject.fileCount, 1); + TEST_EQ((int32_t)arrlen(sBreakpoints), 1); + TEST_EQ(sBreakpoints[0].fileIdx, 0); + + // Remove the active form: designer and form window are torn down. + treeOpenFile(0); + dcLayout(); + TEST_TRUE(sFormWin != NULL); + treeSelectFile(0); + dialogPlanInit(&plan, BTN_YES); + tDialogScript(dialogAnswer, &plan); + ideMenu(CMD_PRJ_REMOVE); + tDialogScript(NULL, NULL); + tPump(SETTLE_FRAMES); + TEST_EQ(sProject.fileCount, 0); + TEST_PTR_EQ(sFormWin, NULL); + TEST_EQ((int32_t)arrlen(sBreakpoints), 0); + + ideExit(); + tShutdown(); +} + + +// Window-menu commands, closing and reopening auxiliary windows, and +// the code-window reopen paths for modules and forms. +static void caseWinCmds(void) { + tCase("window commands, close/reopen paths, edit routing"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + fixturePrepare(); + ideStart(true); + openViaRecent(); + treeOpenFile(FILE_MAIN); + + // Already-open singletons return quietly. + ideMenu(CMD_WIN_OUTPUT); + ideMenu(CMD_WIN_IMM); + ideMenu(CMD_WIN_CODE); + TEST_TRUE(sOutWin != NULL && sImmWin != NULL && sCodeWin != NULL); + + // Debug windows shown while idle, then the show/raise branch. + ideMenu(CMD_WIN_LOCALS); + ideMenu(CMD_WIN_LOCALS); + ideMenu(CMD_WIN_CALLSTACK); + ideMenu(CMD_WIN_CALLSTACK); + ideMenu(CMD_WIN_WATCH); + ideMenu(CMD_WIN_WATCH); + ideMenu(CMD_WIN_BREAKPOINTS); + ideMenu(CMD_WIN_BREAKPOINTS); + TEST_TRUE(sLocalsWin && sCallStackWin && sWatchWin && sBreakpointWin); + + // Their close buttons hide, not destroy. + onLocalsClose(sLocalsWin); + onCallStackClose(sCallStackWin); + onWatchClose(sWatchWin); + onBreakpointWinClose(sBreakpointWin); + tPump(1); + TEST_TRUE(sLocalsWin && !sLocalsWin->visible); + ideMenu(CMD_WIN_LOCALS); + TEST_TRUE(sLocalsWin->visible); + + // Project window: close destroys, the menu recreates. + TEST_TRUE(sProjectWin != NULL); + onProjectWinClose(sProjectWin); + tPump(SETTLE_FRAMES); + TEST_PTR_EQ(sProjectWin, NULL); + ideMenu(CMD_WIN_PROJECT); + TEST_TRUE(sProjectWin != NULL); + ideMenu(CMD_WIN_PROJECT); + + // Edit routing to the last focused content window (the editor). + editorFocus(); + ideMenu(CMD_SELECT_ALL); + ideMenu(CMD_COPY); + ideMenu(CMD_PASTE); + ideMenu(CMD_CUT); + tPump(1); + TEST_TRUE(sProject.files[FILE_MAIN].modified); + ideMenu(CMD_SELECT_ALL); + ideMenu(CMD_PASTE); + tPump(1); + + // Close the code window (stashes the edit), reopen it through + // activateFile on the same module. + onCodeWinClose(sCodeWin); + tPump(SETTLE_FRAMES); + TEST_PTR_EQ(sCodeWin, NULL); + activateFile(FILE_MAIN, ViewCodeE); + TEST_TRUE(sCodeWin != NULL); + TEST_TRUE(strstr(wgtGetText(sEditor), FIXTURE_GLOBAL_LINE) != NULL); + + // Same reopen path for a form's code. + treeOpenFile(FILE_FORM); + ideMenu(CMD_VIEW_CODE); + TEST_TRUE(sCodeWin != NULL); + onCodeWinClose(sCodeWin); + tPump(SETTLE_FRAMES); + activateFile(FILE_FORM, ViewCodeE); + TEST_TRUE(sCodeWin != NULL); + TEST_EQ(sEditorFileIdx, FILE_FORM); + + // Design view of the already-active form raises the open window. + activateFile(FILE_FORM, ViewDesignE); + TEST_TRUE(sFormWin != NULL); + activateFile(FILE_FORM, ViewDesignE); + + // View / Code for a module with the code window hidden: the + // stash-designer-state fallback shows and raises it. + treeOpenFile(FILE_MAIN); + dvxHideWindow(sAc, sCodeWin); + treeSelectFile(FILE_MAIN); + ideMenu(CMD_VIEW_CODE); + TEST_TRUE(sCodeWin && sCodeWin->visible); + + // View toggles persist to the INI (a real click flips the check + // mark before the command fires; do the same). + wmMenuItemSetChecked(sWin->menuBar, CMD_VIEW_TOOLBAR, false); + ideMenu(CMD_VIEW_TOOLBAR); + TEST_TRUE(sToolbar && !sToolbar->visible); + wmMenuItemSetChecked(sWin->menuBar, CMD_VIEW_TOOLBAR, true); + ideMenu(CMD_VIEW_TOOLBAR); + TEST_TRUE(sToolbar && sToolbar->visible); + wmMenuItemSetChecked(sWin->menuBar, CMD_VIEW_STATUS, false); + ideMenu(CMD_VIEW_STATUS); + TEST_TRUE(sStatusBar && !sStatusBar->visible); + wmMenuItemSetChecked(sWin->menuBar, CMD_VIEW_STATUS, true); + ideMenu(CMD_VIEW_STATUS); + + // Debug layout toggle and output-to-log toggle round trip. + wmMenuItemSetChecked(sWin->menuBar, CMD_DEBUG_LAYOUT, true); + ideMenu(CMD_DEBUG_LAYOUT); + wmMenuItemSetChecked(sWin->menuBar, CMD_DEBUG_LAYOUT, false); + ideMenu(CMD_DEBUG_LAYOUT); + wmMenuItemSetChecked(sWin->menuBar, CMD_OUTPUT_TO_LOG, true); + ideMenu(CMD_OUTPUT_TO_LOG); + TEST_TRUE(sOutputToLog); + setOutputText("logged line"); + wmMenuItemSetChecked(sWin->menuBar, CMD_OUTPUT_TO_LOG, false); + ideMenu(CMD_OUTPUT_TO_LOG); + TEST_TRUE(!sOutputToLog); + + // Clear Output and Save on Run toggle. + ideMenu(CMD_CLEAR); + TEST_EQ(sOutputLen, 0); + wmMenuItemSetChecked(sWin->menuBar, CMD_SAVE_ON_RUN, false); + ideMenu(CMD_SAVE_ON_RUN); + wmMenuItemSetChecked(sWin->menuBar, CMD_SAVE_ON_RUN, true); + ideMenu(CMD_SAVE_ON_RUN); + + ideExit(); + tShutdown(); +} + + +// Live widget of controls[idx] (NULL-safe). +static WidgetT *dcCtrlWidget(int32_t idx) { + if (!sDesigner.form || idx < 0 || idx >= (int32_t)arrlen(sDesigner.form->controls)) { + return NULL; + } + + return sDesigner.form->controls[idx]->widget; +} + + +// One full click on the design surface (content coordinates), with the +// clock advanced first so it never pairs into a double-click. +static void dcFormClick(int32_t x, int32_t y) { + tAdvanceClock(DC_CLICK_GAP_MS); + dcFormMouse(x, y, true); + dcFormMouse(x, y, false); + tPump(1); +} + + +// Key press delivered to the form designer window. +static void dcFormKey(int32_t key, int32_t mod) { + TEST_TRUE(sFormWin != NULL); + + if (sFormWin) { + onFormWinKey(sFormWin, key, mod); + } + + tPump(1); +} + + +// Raw designer mouse transition (press/hold/release edge is derived +// from the previous call, exactly like the real dispatcher). +static void dcFormMouse(int32_t x, int32_t y, bool down) { + TEST_TRUE(sFormWin != NULL); + + if (sFormWin) { + onFormWinMouse(sFormWin, x, y, down ? MOUSE_LEFT : 0); + } +} + + +// Repaint so freshly created design widgets get laid out (positions are +// only valid after a paint pass). +static void dcLayout(void) { + if (sFormWin) { + dvxInvalidateWindow(sAc, sFormWin); + } + + tPump(SETTLE_FRAMES); +} + + +// Dialog script for paste: answer the "Paste" control-array question +// with the plan's prompt button; anything else goes to dialogAnswer. +static void dcPasteScript(AppContextT *ctx, WindowT *modal, void *userCtx) { + DialogPlanT *plan = (DialogPlanT *)userCtx; + + if (strcmp(modal->title, "Paste") == 0) { + if (modal->id == plan->lastModalId) { + return; + } + + plan->lastModalId = modal->id; + TEST_TRUE(tDialogClickButton(modal, plan->promptButton)); + return; + } + + dialogAnswer(ctx, modal, userCtx); +} + + +// Arm a toolbox tool and click the design surface at (x, y). +static void dcPlace(const char *toolName, int32_t x, int32_t y) { + TEST_TRUE(dcToolClick(toolName)); + dcFormClick(x, y); +} + + +// Open the fixture form in the designer and let the layout settle. +static void dcPrepareDesign(void) { + treeOpenFile(FILE_FORM); + TEST_TRUE(sFormWin != NULL && sToolboxWin != NULL && sPropsWin != NULL); + TEST_TRUE(sDesigner.form != NULL); + dcLayout(); +} + + +// Click controls[idx] on the canvas (selects it). +static void dcSelectCtrl(int32_t idx) { + WidgetT *w = dcCtrlWidget(idx); + + TEST_TRUE(w != NULL); + + if (w) { + dcFormClick(w->x + w->w / 2, w->y + w->h / 2); + TEST_EQ(sDesigner.selectedIdx, idx); + } +} + + +// Click a toolbox button by its (text-fallback) caption. +static bool dcToolClick(const char *toolName) { + WidgetT *btn = sToolboxWin ? tFindByText(sToolboxWin->widgetRoot, toolName) : NULL; + + TEST_TRUE(btn != NULL); + + if (!btn) { + return false; + } + + btn->onClick(btn); + return true; +} + + +// One scripted action per idle frame inside the Menu Editor dialog. +static void dmMenuScript(AppContextT *ctx, WindowT *modal, void *userCtx) { + (void)ctx; + (void)userCtx; + + if (strcmp(modal->title, "Menu Editor") != 0) { + return; + } + + WidgetT *lb = findOfClass(modal->widgetRoot, sListBoxClass, 0); + + // An error box shares the title but has no list box: dismiss it. + if (!lb) { + sMedErrors++; + tDialogClickButton(modal, BTN_OK); + return; + } + + WidgetT *cap = findOfClass(modal->widgetRoot, sTextInputClass, 0); + WidgetT *nam = findOfClass(modal->widgetRoot, sTextInputClass, 1); + + if (!cap || !nam) { + return; + } + + if (sMedPass == 2) { + tDialogClickButton(modal, "Cancel"); + return; + } + + if (sMedPass == 3) { + // Delete every item: the editor then shows blank fields for the + // empty selection; Cancel leaves the form untouched. + if (sMedStep < DC_MED_STEP_MAX && wgtGetText(cap)[0] != '\0') { + tDialogClickButton(modal, "Delete"); + sMedStep++; + return; + } + + tDialogClickButton(modal, "Cancel"); + return; + } + + if (sMedPass == 1) { + switch (sMedStep) { + case 0: { + // Select the mnuOpen row (index 1) and rename it. + wgtListBoxSetSelected(lb, 1); + lb->onChange(lb); + break; + } + case 1: + TEST_STR_EQ(wgtGetText(nam), "mnuOpen"); + wgtSetText(nam, "mnuShow"); + nam->onChange(nam); + break; + default: + tDialogClickButton(modal, BTN_OK); + break; + } + + sMedStep++; + return; + } + + switch (sMedStep) { + case 0: + wgtSetText(cap, "&File"); + cap->onChange(cap); + TEST_STR_EQ(wgtGetText(nam), "mnuFile"); + break; + case 1: + tDialogClickButton(modal, "Next"); + break; + case 2: + wgtSetText(cap, "&Open"); + cap->onChange(cap); + break; + case 3: + tDialogClickButton(modal, "->"); + break; + case 4: + tDialogClickButton(modal, "Next"); + break; + case 5: + wgtSetText(cap, "-"); + cap->onChange(cap); + TEST_STR_EQ(wgtGetText(nam), "mnuSep1"); + break; + case 6: + tDialogClickButton(modal, "Next"); + break; + case 7: { + WidgetT *checked = tFindByText(modal->widgetRoot, "Checked"); + + wgtSetText(cap, "Deep"); + wgtCheckboxSetChecked(checked, true); + cap->onChange(cap); + break; + } + case 8: + tDialogClickButton(modal, "->"); + break; + case 9: + tDialogClickButton(modal, "Next"); + break; + case 10: { + WidgetT *radioCk = tFindByText(modal->widgetRoot, "RadioCheck"); + WidgetT *enabled = tFindByText(modal->widgetRoot, "Enabled"); + + wgtSetText(cap, "Radio"); + wgtCheckboxSetChecked(radioCk, true); + wgtCheckboxSetChecked(enabled, false); + cap->onChange(cap); + break; + } + case 11: + tDialogClickButton(modal, "<-"); + break; + case 12: + tDialogClickButton(modal, "Up"); + break; + case 13: + tDialogClickButton(modal, "Dn"); + break; + case 14: + tDialogClickButton(modal, "Insert"); + break; + case 15: + wgtSetText(cap, "Gone"); + cap->onChange(cap); + break; + case 16: + tDialogClickButton(modal, "Delete"); + break; + case 17: + // Back to the first item; make it a popup with a manual name. + wgtListBoxSetSelected(lb, 0); + lb->onChange(lb); + break; + case 18: { + WidgetT *popup = tFindByText(modal->widgetRoot, "Popup"); + + wgtCheckboxSetChecked(popup, true); + cap->onChange(cap); + wgtSetText(nam, "mnuMain"); + nam->onChange(nam); + break; + } + case 19: + // Name collision with a control: rejected. + wgtSetText(nam, FIXTURE_BUTTON_NAME); + nam->onChange(nam); + tDialogClickButton(modal, BTN_OK); + break; + case 20: + // Not an identifier: rejected. + wgtSetText(nam, "bad name"); + nam->onChange(nam); + tDialogClickButton(modal, BTN_OK); + break; + case 21: + wgtSetText(nam, "mnuMain"); + nam->onChange(nam); + tDialogClickButton(modal, BTN_OK); + break; + default: + tDialogClickButton(modal, "Cancel"); + break; + } + + sMedStep++; +} + + +// Scripted driver for the Project Properties dialog and its nested +// file dialogs / prompts. +static void dmPpdScript(AppContextT *ctx, WindowT *modal, void *userCtx) { + (void)ctx; + (void)userCtx; + + const char *title = modal->title; + + if (strcmp(title, TITLE_INVALID_ICON) == 0) { + sPpdErrors++; + tDialogClickButton(modal, BTN_OK); + return; + } + + if (strcmp(title, "Copy Icon") == 0 || strcmp(title, "Overwrite") == 0 || strcmp(title, "Confirm Save") == 0) { + tDialogClickButton(modal, BTN_YES); + return; + } + + if (strcmp(title, "Select Icon") == 0 || strcmp(title, "Select Help File") == 0) { + if (sPpdStep == 7 || sPpdStep == 8) { + // Cancelled browses. + tDialogClickButton(modal, "Cancel"); + return; + } + + WidgetT *pathInput = findOfClass(modal->widgetRoot, sTextInputClass, 0); + WidgetT *nameInput = findOfClass(modal->widgetRoot, sTextInputClass, 1); + bool isIcon = (strcmp(title, "Select Icon") == 0); + const char *dir = sProjectDir; + const char *name = "readme.hlp"; + + if (isIcon) { + if (sPpdStep == 2) { + name = "notimg.txt"; + } else if (sPpdStep == 3) { + name = "small.bmp"; + } else { + dir = tTempDir(); + name = "icon32.bmp"; + } + } + + TEST_TRUE(pathInput != NULL && nameInput != NULL); + wgtSetText(pathInput, dir); + + if (pathInput->onKeyDown) { + pathInput->onKeyDown(pathInput, KEY_ENTER, 0); + } + + wgtSetText(nameInput, name); + tDialogClickButton(modal, isIcon ? BTN_OPEN : BTN_SAVE); + return; + } + + if (strcmp(title, "Project Properties") != 0) { + return; + } + + if (sPpdStep == 100) { + // Cancel pass. + WidgetT *nameInput = findOfClass(modal->widgetRoot, sTextInputClass, 0); + + wgtSetText(nameInput, "ShouldNotStick"); + tDialogClickButton(modal, "Cancel"); + return; + } + + if (sPpdStep == 300) { + if (modal->onClose) { + modal->onClose(modal); + } + + return; + } + + if (sPpdStep >= 200) { + // The icon on disk is gone: OK is rejected once, then Cancel. + if (sPpdStep == 200) { + tDialogClickButton(modal, BTN_OK); + } else { + tDialogClickButton(modal, "Cancel"); + } + + sPpdStep++; + return; + } + + switch (sPpdStep) { + case 0: { + WidgetT *inputs[6]; + + for (int32_t i = 0; i < 6; i++) { + inputs[i] = findOfClass(modal->widgetRoot, sTextInputClass, i); + TEST_TRUE(inputs[i] != NULL); + } + + wgtSetText(inputs[1], "Anna"); + wgtSetText(inputs[2], "Pub"); + wgtSetText(inputs[3], "2.1"); + wgtSetText(inputs[4], "(c)"); + + WidgetT *desc = findOfClass(modal->widgetRoot, sTextAreaClass, 0); + + if (desc) { + wgtSetText(desc, "About the fixture"); + } + + WidgetT *dropdown = findOfClass(modal->widgetRoot, sDropdownClass, 0); + + TEST_TRUE(dropdown != NULL); + + if (dropdown) { + wgtDropdownSetSelected(dropdown, 0); + } + + WidgetT *optEx = findOfClass(modal->widgetRoot, sCheckboxClass, 0); + + TEST_TRUE(optEx != NULL); + + if (optEx) { + wgtCheckboxSetChecked(optEx, true); + } + + break; + } + case 1: + // Help file browse (second Browse button). + tWidgetClick(dpFindNthByText(modal->widgetRoot, "Browse...", 1)); + break; + case 2: + case 3: + case 4: + case 5: + // Icon browse: invalid file, wrong size, outside copy, + // outside copy again (overwrite). + tWidgetClick(dpFindNthByText(modal->widgetRoot, "Browse...", 0)); + break; + case 6: + // The step-7/8 browses are cancelled in the file dialog. + tWidgetClick(dpFindNthByText(modal->widgetRoot, "Browse...", 0)); + break; + case 7: + tWidgetClick(dpFindNthByText(modal->widgetRoot, "Browse...", 1)); + break; + default: + tDialogClickButton(modal, "OK"); + break; + } + + sPpdStep++; +} + + +// Minimal 24-bit BMP writer for icon validation tests. +static void dmWriteBmp(const char *path, int32_t w, int32_t h) { + int32_t rowBytes = (w * 3 + 3) & ~3; + int32_t dataSize = rowBytes * h; + int32_t fileSize = DM_BMP_HDR + dataSize; + uint8_t *buf = (uint8_t *)calloc(1, (size_t)fileSize); + + TEST_TRUE(buf != NULL); + + if (!buf) { + return; + } + + buf[0] = 'B'; + buf[1] = 'M'; + memcpy(buf + 2, &fileSize, 4); + buf[10] = DM_BMP_HDR; + buf[14] = 40; + memcpy(buf + 18, &w, 4); + memcpy(buf + 22, &h, 4); + buf[26] = 1; + buf[28] = 24; + memcpy(buf + 34, &dataSize, 4); + TEST_TRUE(tWriteFile(path, buf, fileSize)); + free(buf); +} + + +// Control record by name (first match). +static DsgnControlT *dpCtrlByName(const char *name) { + for (int32_t i = 0; i < (int32_t)arrlen(sDesigner.form->controls); i++) { + if (strcasecmp(sDesigner.form->controls[i]->name, name) == 0) { + return sDesigner.form->controls[i]; + } + } + + return NULL; +} + + +// Modal script hook: dismiss any single-OK box (About etc). +static void dpDismissOk(WindowT *modal) { + tDialogClickButton(modal, BTN_OK); +} + + +// Double-click one row of the properties grid under a dialog plan. +static void dpEditRow(const char *rowName, DialogPlanT *plan) { + WidgetT *list = findOfClass(sPropsWin->widgetRoot, sListViewClass, 0); + + TEST_TRUE(list != NULL && list->onDblClick != NULL); + + if (!list || !list->onDblClick) { + return; + } + + int32_t row = listRowByName(list, rowName); + + TEST_TRUE(row >= 0); + + if (row < 0) { + return; + } + + tDialogScript(dialogAnswer, plan); + wgtListViewSetSelected(list, row); + list->onDblClick(list); + tPump(1); + tDialogScript(NULL, NULL); +} + + +// nth widget (depth-first) under root whose text matches. +static WidgetT *dpFindNthByText(WidgetT *root, const char *text, int32_t nth) { + WidgetT *hit = NULL; + int32_t seen = 0; + + for (WidgetT *c = root; c; ) { + const char *wText = wgtGetText(c); + + if (wText && strcmp(wText, text) == 0) { + if (seen == nth) { + hit = c; + break; + } + + seen++; + } + + if (c->firstChild) { + c = c->firstChild; + } else { + while (c && c != root && !c->nextSibling) { + c = c->parent; + } + + c = (c && c != root) ? c->nextSibling : NULL; + } + } + + return hit; +} + + +// Click a Find-dialog radio by caption through the full input path. +// The dialog is raised first: a search that activated a file may have +// raised the (full-width) code window over it, which would swallow the +// click. +static void dpFindRadio(const char *caption) { + WidgetT *radio = sFindWin ? tFindByText(sFindWin->widgetRoot, caption) : NULL; + int32_t sx = 0; + int32_t sy = 0; + + TEST_TRUE(radio != NULL); + + if (radio) { + dvxRaiseWindow(sAc, sFindWin); + tPump(1); + tWidgetCenter(radio, &sx, &sy); + tAdvanceClock(DC_CLICK_GAP_MS); + tClick(sx, sy); + } +} + + +// Index of a layout name in the choice list the Layout editor builds +// (same enumeration order as onPropDblClick). +static int32_t dpLayoutIndex(const char *layoutName) { + int32_t count = 0; + int32_t found = -1; + int32_t total = wgtIfaceCount(); + + for (int32_t i = 0; i < total; i++) { + const WgtIfaceT *iface = wgtIfaceAt(i, NULL); + + if (iface && iface->isContainer && iface->createSig == WGT_CREATE_PARENT && iface->basName) { + if (strcasecmp(iface->basName, layoutName) == 0) { + found = count; + } + + count++; + } + } + + TEST_TRUE(found >= 0); + return found; +} + + +// Preferences custom script: drive the Colors tab, then OK. +static void dpPrefsColors(WindowT *modal) { + TEST_TRUE(sPrefsDlg.colorList != NULL && sPrefsDlg.sliderR != NULL); + wgtListBoxSetSelected(sPrefsDlg.colorList, SYNTAX_STRING); + onColorListChange(sPrefsDlg.colorList); + wgtSliderSetValue(sPrefsDlg.sliderR, DP_TEST_R); + onColorSliderChange(sPrefsDlg.sliderR); + wgtSliderSetValue(sPrefsDlg.sliderG, DP_TEST_G); + onColorSliderChange(sPrefsDlg.sliderG); + wgtSliderSetValue(sPrefsDlg.sliderB, DP_TEST_B); + onColorSliderChange(sPrefsDlg.sliderB); + TEST_STR_EQ(wgtGetText(sPrefsDlg.lblR), "16"); + TEST_TRUE(tDialogClickButton(modal, BTN_OK)); +} + + +// Select a control on the canvas by record pointer. +static void dpSelectByPtr(DsgnControlT *ctrl) { + for (int32_t i = 0; i < (int32_t)arrlen(sDesigner.form->controls); i++) { + if (sDesigner.form->controls[i] == ctrl) { + sDesigner.selectedIdx = i; + prpRefresh(&sDesigner); + tPump(1); + return; + } + } + + TEST_TRUE(!"control not found"); +} + + +// Append a detached tree item as the last child of parent. +static void dpTreeAppend(WidgetT *parent, WidgetT *item) { + item->parent = parent; + item->nextSibling = NULL; + + if (parent->lastChild) { + parent->lastChild->nextSibling = item; + } else { + parent->firstChild = item; + } + + parent->lastChild = item; +} + + +// Tree item under parent whose label starts with prefix (recursive). +static WidgetT *dpTreeFind(WidgetT *parent, const char *prefix) { + for (WidgetT *item = parent->firstChild; item; item = item->nextSibling) { + const char *label = (const char *)item->userData; + + if (label && strncasecmp(label, prefix, strlen(prefix)) == 0) { + return item; + } + + WidgetT *hit = dpTreeFind(item, prefix); + + if (hit) { + return hit; + } + } + + return NULL; +} + + +// Insert a detached tree item as the first child of parent. +static void dpTreeInsertFirst(WidgetT *parent, WidgetT *item) { + item->parent = parent; + item->nextSibling = parent->firstChild; + parent->firstChild = item; + + if (!parent->lastChild) { + parent->lastChild = item; + } +} + + +void runDesignCases(void) { + caseDsgnPlaceTools(); + caseDsgnSelectResizeReorder(); + caseDsgnDeleteTree(); + caseDsgnCopyPaste(); + caseDsgnFrmRoundTrip(); + caseDsgnBadAndMissingFiles(); + casePropGrid(); + casePropTree(); + caseMenuEd(); + casePrjDialogs(); + caseRecentAndRemove(); + caseProcSplit(); + caseFindScopes(); + caseGapData(); + caseGapMisc(); + caseGapTbxIcons(); + casePrefsColors(); + caseHelpQuery(); + caseWinCmds(); +} diff --git a/src/test/ide/testStub.c b/src/test/ide/testStub.c new file mode 100644 index 0000000..6aa6ba4 --- /dev/null +++ b/src/test/ide/testStub.c @@ -0,0 +1,277 @@ +// 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. + +// testStub.c -- the standalone BASIC application stub (basstub.c) +// driven under the dvxtest harness. +// +// basstub.c is #included so its appMain runs in-process against the +// host platform. A real .app is produced by the release bascomp, and +// the error paths are fed files without resources, without a MODULE, +// and with a corrupt MODULE. + +#include "dvxtest.h" +#include "stub/basstub.c" + +#include +#include +#include +#include +#include + +#define SCREEN_W 800 +#define SCREEN_H 600 +#define SCREEN_BPP 32 +#define SETTLE_FRAMES 3 +#define EXEC_MODE 0755 +#define CMD_BUF (DVX_MAX_PATH * 3 + 64) +#define FORM_TITLE "Stub Form" +#define FORM_WAIT_MAX 400 +#define APP_NAME_RES "StubApp" +#define INPUT_REPLY "9" + +typedef struct { + int32_t dismissed; // error boxes clicked away +} StubPlanT; + + +static const char *STUB_DBP_TEXT = + "[Project]\n" + "Name = " APP_NAME_RES "\n" + "HelpFile = stub.hlp\n" + "\n" + "[Modules]\n" + "File0 = main.bas\n" + "Count = 1\n" + "\n" + "[Forms]\n" + "File0 = form1.frm\n" + "Count = 1\n" + "\n" + "[Settings]\n" + "StartupForm = StubForm\n"; + +static const char *STUB_BAS_TEXT = + "Dim Shared n As Integer\n" + "PRINT \"boot\"\n" + "DoEvents\n" + "INPUT n\n" + "PRINT n\n"; + +static const char *STUB_FRM_TEXT = + "VERSION DVX 1.00\n" + "Begin Form StubForm\n" + " Caption = \"" FORM_TITLE "\"\n" + " Begin CommandButton btnOne\n" + " Caption = \"One\"\n" + " End\n" + "End\n"; + +static void caseStubErrors(void); +static void caseStubRun(void); +static bool copyFile(const char *src, const char *dst, bool exec); +static void dismissError(AppContextT *ctx, WindowT *modal, void *userCtx); +static void prepareProject(char *outDbp, int32_t outSize); +ShellAppT *shellGetApp(int32_t appId); +static void stubIdleDrive(void *userCtx); +int main(void); + +static ShellAppT sShellApp; +static bool sSawForm = false; + + +// Error paths: a file with no resource directory, one without a MODULE +// resource, and one whose MODULE does not deserialize. +static void caseStubErrors(void) { + tCase("stub rejects missing/corrupt resources"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + DxeAppContextT dxe; + StubPlanT plan; + char path[DVX_MAX_PATH]; + + // 1. Plain text file: no resource directory at all. + snprintf(path, sizeof(path), "%s" DVX_PATH_SEP "plain.app", tTempDir()); + TEST_TRUE(tWriteFile(path, "not an app", (int32_t)strlen("not an app"))); + memset(&dxe, 0, sizeof(dxe)); + dxe.shellCtx = tCtx(); + dxe.appId = 1; + snprintf(dxe.appPath, sizeof(dxe.appPath), "%s", path); + plan.dismissed = 0; + tDialogScript(dismissError, &plan); + TEST_EQ(appMain(&dxe), 1); + TEST_EQ(plan.dismissed, 1); + + // 2. Resources but no MODULE: the release compiler binary itself + // (it carries STUB and noicon resources). + snprintf(path, sizeof(path), "%s" DVX_PATH_SEP "nomodule.app", tTempDir()); + TEST_TRUE(copyFile(IDETEST_BASCOMP, path, false)); + snprintf(dxe.appPath, sizeof(dxe.appPath), "%s", path); + plan.dismissed = 0; + TEST_EQ(appMain(&dxe), 1); + TEST_EQ(plan.dismissed, 1); + + // 3. A MODULE resource that does not deserialize. + snprintf(path, sizeof(path), "%s" DVX_PATH_SEP "corrupt.app", tTempDir()); + TEST_TRUE(tWriteFile(path, "stub bytes", (int32_t)strlen("stub bytes"))); + TEST_EQ(dvxResAppend(path, BAS_RES_MODULE, DVX_RES_BINARY, "garbage!", (uint32_t)strlen("garbage!")), 0); + snprintf(dxe.appPath, sizeof(dxe.appPath), "%s", path); + plan.dismissed = 0; + TEST_EQ(appMain(&dxe), 1); + TEST_EQ(plan.dismissed, 1); + tDialogScript(NULL, NULL); + + tPump(SETTLE_FRAMES); + tShutdown(); +} + + +// The happy path: compile the fixture project with bascomp, run the +// resulting .app through appMain, close its form from the idle hook. +static void caseStubRun(void) { + tCase("stub loads and runs a compiled .app"); + tInit(SCREEN_W, SCREEN_H, SCREEN_BPP); + + char dbpPath[DVX_MAX_PATH]; + char appPath[DVX_MAX_PATH]; + char cmd[CMD_BUF]; + + prepareProject(dbpPath, sizeof(dbpPath)); + snprintf(appPath, sizeof(appPath), "%s" DVX_PATH_SEP "fix.app", tTempDir()); + snprintf(cmd, sizeof(cmd), "%s %s -o %s > /dev/null", IDETEST_BASCOMP, dbpPath, appPath); + TEST_EQ(system(cmd), 0); + + DxeAppContextT dxe; + + memset(&dxe, 0, sizeof(dxe)); + dxe.shellCtx = tCtx(); + dxe.appId = 1; + snprintf(dxe.appPath, sizeof(dxe.appPath), "%s", appPath); + snprintf(dxe.appDir, sizeof(dxe.appDir), "%s", tTempDir()); + memset(&sShellApp, 0, sizeof(sShellApp)); + sSawForm = false; + tCtx()->idleCallback = stubIdleDrive; + tCtx()->idleCtx = NULL; + + TEST_EQ(appMain(&dxe), 0); + + tCtx()->idleCallback = NULL; + TEST_TRUE(sSawForm); + TEST_STR_EQ(sShellApp.name, APP_NAME_RES); + TEST_TRUE(strstr(dxe.helpFile, "stub.hlp") != NULL); + appShutdown(); + tPump(SETTLE_FRAMES); + tShutdown(); +} + + +static bool copyFile(const char *src, const char *dst, bool exec) { + int32_t len = 0; + char *data = platformReadFile(src, &len); + bool ok = data != NULL && len > 0 && tWriteFile(dst, data, len); + + if (ok && exec) { + ok = (chmod(dst, EXEC_MODE) == 0); + } + + free(data); + return ok; +} + + +// Modal script: click OK on the stub's error boxes. +static void dismissError(AppContextT *ctx, WindowT *modal, void *userCtx) { + StubPlanT *plan = (StubPlanT *)userCtx; + + (void)ctx; + + if (tDialogClickButton(modal, "OK")) { + plan->dismissed++; + } +} + + +// Write the stub project (module with PRINT / DoEvents / INPUT, one +// form, a help file) into the temp dir. +static void prepareProject(char *outDbp, int32_t outSize) { + char path[DVX_MAX_PATH]; + + snprintf(path, sizeof(path), "%s" DVX_PATH_SEP "main.bas", tTempDir()); + TEST_TRUE(tWriteFile(path, STUB_BAS_TEXT, (int32_t)strlen(STUB_BAS_TEXT))); + snprintf(path, sizeof(path), "%s" DVX_PATH_SEP "form1.frm", tTempDir()); + TEST_TRUE(tWriteFile(path, STUB_FRM_TEXT, (int32_t)strlen(STUB_FRM_TEXT))); + snprintf(path, sizeof(path), "%s" DVX_PATH_SEP "stub.hlp", tTempDir()); + TEST_TRUE(tWriteFile(path, "help", (int32_t)strlen("help"))); + snprintf(outDbp, outSize, "%s" DVX_PATH_SEP "stub.dbp", tTempDir()); + TEST_TRUE(tWriteFile(outDbp, STUB_DBP_TEXT, (int32_t)strlen(STUB_DBP_TEXT))); +} + + +// The shell registry stub basstub.c updates with the app name. +ShellAppT *shellGetApp(int32_t appId) { + (void)appId; + return &sShellApp; +} + + +// Idle driver while the app runs: answer the INPUT box (its text +// field takes focus when the dialog opens), then close the form as +// soon as it appears -- through the runtime's own onClose, because a +// bare dvxDestroyWindow would bypass the unload and spin forever. +static void stubIdleDrive(void *userCtx) { + (void)userCtx; + + WindowT *modal = tCtx()->modalWindow; + + if (modal && strcmp(modal->title, "Input") == 0) { + WidgetT *focused = wgtGetFocused(); + + if (focused) { + wgtSetText(focused, INPUT_REPLY); + } + + tDialogClickButton(modal, "OK"); + return; + } + + WindowT *win = tFindWindow(FORM_TITLE); + + if (win && !sSawForm) { + sSawForm = true; + + if (win->onClose) { + win->onClose(win); + } + } +} + + +int main(void) { + setvbuf(stdout, NULL, _IOLBF, 0); + platformSetLogPath("testStub.log"); + tSuiteBegin("T6 stub"); + TEST_TRUE(tProcessInit(DVXTEST_WIDGET_DIR) > 0); + + caseStubRun(); + caseStubErrors(); + + return tSuiteEnd(); +} diff --git a/src/test/lint/Makefile b/src/test/lint/Makefile new file mode 100644 index 0000000..b535e9e --- /dev/null +++ b/src/test/lint/Makefile @@ -0,0 +1,29 @@ +# src/test/lint/Makefile -- T11 static + style gate. +# +# make run styleLint, asciiCheck, staticGate (fails on new findings) +# make baseline regenerate all three baselines from the current tree +# make style styleLint only +# make ascii asciiCheck only +# make static staticGate (cppcheck + -fanalyzer; slow) +# make cppcheck staticGate cppcheck pass only + +.PHONY: all style ascii static cppcheck baseline + +all: style ascii static + +style: + python3 ./styleLint.py + +ascii: + ./asciiCheck.sh + +static: + ./staticGate.sh + +cppcheck: + ./staticGate.sh --cppcheck-only + +baseline: + python3 ./styleLint.py --update-baseline --summary + ./asciiCheck.sh --update + ./staticGate.sh --update diff --git a/src/test/lint/__pycache__/styleLint.cpython-312.pyc b/src/test/lint/__pycache__/styleLint.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6150cfe8a6403dc098cefb4647284b59a5fdd713 GIT binary patch literal 29825 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 literal 0 HcmV?d00001 diff --git a/src/test/lint/analyzerBaseline.txt b/src/test/lint/analyzerBaseline.txt new file mode 100644 index 0000000..da094ae --- /dev/null +++ b/src/test/lint/analyzerBaseline.txt @@ -0,0 +1,77 @@ +# analyzer baseline -- generated by staticGate.sh --update +# format: +4 src/apps/kpunch/dvxbasic/compiler/obfuscate.c|nameMapAdd.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|basFormRtInitFormVars|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|findNamedObjectInForm.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/apps/kpunch/dvxbasic/ide/ideDesigner.c|loadOnFormEnd|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|recentAdd.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|saveCurProc|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|updateDropdowns|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/apps/kpunch/dvxbasic/ide/ideMenuEditor.c|rebuildList|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/apps/kpunch/dvxbasic/ide/ideProperties.c|addPropRow|leak of [CWE-401] [-Wanalyzer-malloc-leak] +2 src/apps/kpunch/dvxbasic/runtime/vm.c|allocArray|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +63 src/apps/kpunch/dvxbasic/runtime/vm.c|basVmStep|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +14 src/apps/kpunch/dvxbasic/runtime/vm.c|execArith|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +11 src/apps/kpunch/dvxbasic/runtime/vm.c|execCompare|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +7 src/apps/kpunch/dvxbasic/runtime/vm.c|execFileOp|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +2 src/apps/kpunch/dvxbasic/runtime/vm.c|execLogical|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +4 src/apps/kpunch/dvxbasic/runtime/vm.c|execStringOp|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +3 src/libs/kpunch/dvxshell/shellApp.c|shellLoadAppInternal|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/libdvx/dvxDialog.c|fdLoadDir|leak of [CWE-401] [-Wanalyzer-malloc-leak] +2 src/libs/kpunch/libdvx/dvxPrefs.c|prefsSetString|leak of [CWE-401] [-Wanalyzer-malloc-leak] +4 src/libs/kpunch/libdvx/dvxPrefs.c|strcmpci|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +5 src/libs/kpunch/libdvx/dvxResource.c|dvxResRemove|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/libdvx/dvxWm.c|wmResizeCommit.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|addPropRow|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|collectGlobFiles|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|dataCtrlAddNew|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|dataCtrlRefresh|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|dvxReadDir|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|listViewRemapOwnedCells|leak of [CWE-401] [-Wanalyzer-malloc-leak] +4 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|prefsSetString|leak of [CWE-401] [-Wanalyzer-malloc-leak] +6 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|stbds_arrgrowf|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +4 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|stbds_make_hash_index|dereference of possibly-NULL [CWE-690] [-Wanalyzer-possible-null-dereference] +12 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|stbds_stralloc|dereference of possibly-NULL [CWE-690] [-Wanalyzer-possible-null-dereference] +1 src/libs/kpunch/libdvx/thirdparty/stb_image_write.h|stbiw__write_hdr_scanline|dereference of possibly-NULL [CWE-690] [-Wanalyzer-possible-null-dereference] +1 src/libs/kpunch/libtasks/taskswitch.c|tsCreate|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/listhelp/listHelp.c|widgetAdoptOwnedStrings|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/sql/thirdparty/sqlite/src/btree.c|getOverflowPage|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +1 src/libs/kpunch/sql/thirdparty/sqlite/src/build.c|sqlite3CreateIndex|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +1 src/libs/kpunch/sql/thirdparty/sqlite/src/callback.c|synthCollSeq|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +4 src/libs/kpunch/sql/thirdparty/sqlite/src/date.c|parseHhMmSs|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +1 src/libs/kpunch/sql/thirdparty/sqlite/src/insert.c|xferOptimization|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +1 src/libs/kpunch/sql/thirdparty/sqlite/src/os_unix.c|unixClose|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/sql/thirdparty/sqlite/src/update.c|sqlite3Update|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +1 src/libs/kpunch/sql/thirdparty/sqlite/src/vdbe.c|applyNumericAffinity|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +36 src/libs/kpunch/sql/thirdparty/sqlite/src/vdbe.c|sqlite3VdbeExec|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +1 src/libs/kpunch/sql/thirdparty/sqlite/src/vdbeaux.c|sqlite3VdbeIdxRowid|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +2 src/libs/kpunch/sql/thirdparty/sqlite/src/vdbeblob.c|sqlite3_blob_open|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +1 src/libs/kpunch/sql/thirdparty/sqlite/tool/lemon.c|ReportTable|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/sql/thirdparty/sqlite/tool/lemon.c|Symbol_new|dereference of NULL [CWE-476] [-Wanalyzer-null-dereference] +1 src/libs/kpunch/sql/thirdparty/sqlite/tool/lemon.c|print_stack_union|dereference of possibly-NULL [CWE-690] [-Wanalyzer-possible-null-dereference] +2 src/libs/kpunch/sql/thirdparty/sqlite/tool/lemon.c|tplt_open|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/libs/kpunch/sql/thirdparty/sqlite/tool/lemon.c|translate_code|leak of [CWE-401] [-Wanalyzer-malloc-leak] +2 src/tools/dvxResWrite.h|dvxResAppendEntry|leak of [CWE-401] [-Wanalyzer-malloc-leak] +9 src/tools/dvxResWrite.h|dvxResAppendEntry|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +3 src/tools/dvxResWrite.h|dvxResWriteBlock|use of uninitialized value [CWE-457] [-Wanalyzer-use-of-uninitialized-value] +1 src/tools/dvxhlpc.c|addRecord|leak of [CWE-401] [-Wanalyzer-malloc-leak] +2 src/tools/dvxhlpc.c|addTrigram|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/tools/dvxhlpc.c|bufAppend|leak of [CWE-401] [-Wanalyzer-malloc-leak] +2 src/tools/dvxhlpc.c|strTableAdd.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/canvas/widgetCanvas.c|wgtCanvasResize|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/comboBox/widgetComboBox.c|wgtComboBoxAddItem.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/comboBox/widgetComboBox.c|wgtComboBox|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/dataCtrl/widgetDataCtrl.c|dataCtrlRefresh|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/dataCtrl/widgetDataCtrl.c|dataCtrlSetField|leak of [CWE-401] [-Wanalyzer-malloc-leak] +2 src/widgets/kpunch/dbGrid/widgetDbGrid.c|dbGridBuildSortIndex|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/dropdown/widgetDropdown.c|wgtDropdownAddItem.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/listBox/widgetListBox.c|listBoxAllocSelBits|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/listBox/widgetListBox.c|wgtListBoxAddItem.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +3 src/widgets/kpunch/listView/widgetListView.c|allocListViewSelBits|leak of [CWE-401] [-Wanalyzer-malloc-leak] +2 src/widgets/kpunch/listView/widgetListView.c|basSetColumns|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/listView/widgetListView.c|listViewAdoptCells|leak of [CWE-401] [-Wanalyzer-malloc-leak] +3 src/widgets/kpunch/listView/widgetListView.c|resolveColumnWidths|leak of [CWE-401] [-Wanalyzer-malloc-leak] +2 src/widgets/kpunch/listView/widgetListView.c|wgtListViewAddItem.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/listView/widgetListView.c|wgtListViewSetCell.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/listView/widgetListView.c|wgtListViewSetData.part.0|leak of [CWE-401] [-Wanalyzer-malloc-leak] +1 src/widgets/kpunch/textInput/widgetTextInput.c|wgtTextInput|leak of [CWE-401] [-Wanalyzer-malloc-leak] diff --git a/src/test/lint/asciiBaseline.txt b/src/test/lint/asciiBaseline.txt new file mode 100644 index 0000000..2852c02 --- /dev/null +++ b/src/test/lint/asciiBaseline.txt @@ -0,0 +1,2 @@ +# asciiCheck baseline -- generated by asciiCheck.sh --update +# format: diff --git a/src/test/lint/asciiCheck.sh b/src/test/lint/asciiCheck.sh new file mode 100755 index 0000000..012b0da --- /dev/null +++ b/src/test/lint/asciiCheck.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# +# asciiCheck.sh -- fail if any text file under src/ contains a non-ASCII +# byte. Thirdparty code (and sqlite) is excluded. +# +# Usage: +# asciiCheck.sh [--update] [--strict] [DIR ...] # default: src +# +# asciiBaseline.txt (next to this script) holds " " for the +# files that already contain non-ASCII lines; the gate fails only when a +# file's offending line count grows or a new file appears. --update +# regenerates it; --strict ignores it and fails on any non-ASCII byte. +# +# Exit status: 0 = clean / no new, 1 = new non-ASCII lines found. + +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +BASELINE="$HERE/asciiBaseline.txt" +UPDATE=0 +STRICT=0 +TARGETS=() +for arg in "$@"; do + case "$arg" in + --update) UPDATE=1 ;; + --strict) STRICT=1 ;; + *) TARGETS+=("$arg") ;; + esac +done +if [ "${#TARGETS[@]}" -eq 0 ]; then + TARGETS=(src) +fi + +cd "$HERE/../../.." + +CURRENT="$(mktemp)" +trap 'rm -f "$CURRENT"' EXIT + +FOUND=0 +NEW=0 +while IFS= read -r -d '' f; do + count="$(LC_ALL=C grep -c -P '[^\x00-\x7F]' "$f" 2>/dev/null || true)" + count="${count:-0}" + if [ "$count" -eq 0 ]; then + continue + fi + echo "$count $f" >> "$CURRENT" + FOUND=$((FOUND + 1)) + baseCount=0 + if [ "$STRICT" -eq 0 ] && [ "$UPDATE" -eq 0 ] && [ -f "$BASELINE" ]; then + baseCount="$(awk -v k="$f" '$2 == k { print $1 }' "$BASELINE" | head -n 1)" + baseCount="${baseCount:-0}" + fi + if [ "$count" -gt "$baseCount" ]; then + LC_ALL=C grep -n -P '[^\x00-\x7F]' "$f" | sed "s|^|$f:|" | sed 's/$/ <-- non-ASCII/' + NEW=$((NEW + count - baseCount)) + fi +done < <(find "${TARGETS[@]}" \ + \( -name thirdparty -o -name sqlite -o -name .git \) -prune -o \ + -type f ! -iname '*baseline.txt' \( -name '*.c' -o -name '*.h' -o -name '*.dhs' -o -name '*.bhs' -o -name '*.md' -o -name '*.bas' -o -name '*.frm' -o -name '*.dbp' -o -name '*.py' -o -name '*.sh' -o -name '*.txt' -o -name '*.hcf' -o -name Makefile \) \ + -print0) + +if [ "$UPDATE" -eq 1 ]; then + { + echo "# asciiCheck baseline -- generated by asciiCheck.sh --update" + echo "# format: " + LC_ALL=C sort -k2 "$CURRENT" 2>/dev/null + } > "$BASELINE" + echo "asciiCheck: baseline written ($FOUND file(s)) -> $BASELINE" + exit 0 +fi + +echo "asciiCheck: $FOUND file(s) with non-ASCII bytes, $NEW new line(s)" +if [ "$NEW" -ne 0 ]; then + echo "asciiCheck: FAIL" + exit 1 +fi +echo "asciiCheck: PASS" +exit 0 diff --git a/src/test/lint/baseline.txt b/src/test/lint/baseline.txt new file mode 100644 index 0000000..6de116f --- /dev/null +++ b/src/test/lint/baseline.txt @@ -0,0 +1,767 @@ +# styleLint baseline -- generated by styleLint.py --update-baseline +# format: || +1 src/apps/kpunch/clock/clock.c|plainInt|snprintf(sState.timeStr, sizeof(sState.timeStr), "", (int)hour12, tm->tm_min, tm->tm_sec, ampm); +1 src/apps/kpunch/cpanel/cpanel.c|plainInt|snprintf(buf, sizeof(buf), "", (long)count); +2 src/apps/kpunch/cpanel/cpanel.c|plainInt|snprintf(buf, sizeof(buf), "", (long)val); +1 src/apps/kpunch/cpanel/cpanel.c|plainInt|snprintf(buf, sizeof(buf), "", (long)wgtSliderGetValue(sDblClickSldr)); +1 src/apps/kpunch/cpanel/cpanel.c|plainInt|snprintf(msgBuf, sizeof(msgBuf), "", (long)secondsLeft); +1 src/apps/kpunch/cpanel/cpanel.c|plainInt|snprintf(sLabelBufs[i], sizeof(sLabelBufs[i]), "", (long)sVideoModes[i].w, (long)sVideoModes[i].h, depthName); +1 src/apps/kpunch/dvxbasic/basBuild.c|plainInt|buildLog(spec, "", (int)frmCount); +1 src/apps/kpunch/dvxbasic/basBuild.c|plainInt|buildLog(spec, "", (int)removed); +1 src/apps/kpunch/dvxbasic/basBuild.c|plainInt|snprintf(key, sizeof(key), "", (int)i); +1 src/apps/kpunch/dvxbasic/basBuild.c|plainInt|snprintf(resName, sizeof(resName), BAS_RES_FORM_FMT, (long)outIdx); +1 src/apps/kpunch/dvxbasic/compiler/lexer.c|plainInt|long long val = strtoll(lex->token.text, NULL, 10); +1 src/apps/kpunch/dvxbasic/compiler/lexer.c|plainInt|snprintf(buf, sizeof(buf), "", (int)BAS_MAX_STRING_LEN); +1 src/apps/kpunch/dvxbasic/compiler/lexer.c|plainInt|snprintf(lex->error, sizeof(lex->error), "", (int)lex->line, (int)lex->col, msg); +1 src/apps/kpunch/dvxbasic/compiler/obfuscate.c|plainInt|snprintf(mapped, sizeof(mapped), "", (long)(m->count + 1)); +2 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(buf, sizeof(buf), "", (int)(BAS_MAX_IDENT - 1)); +1 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(buf, sizeof(buf), "", (int)BAS_MAX_PARAMS); +1 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(buf, sizeof(buf), "", (int)BAS_VM_MAX_CALL_ARGS); +1 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(buf, sizeof(buf), "", (int)UINT16_MAX); +1 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(buf, sizeof(buf), "", (int)p->sym.nextGlobalIdx, (int)BAS_VM_MAX_GLOBALS); +1 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(buf, sizeof(buf), "", builtin->name, (int)builtin->minArgs, (int)builtin->maxArgs, (int)argc); +1 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(buf, sizeof(buf), "", varName, (int)BAS_MAX_IDENT); +1 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(buf, sizeof(buf), "", what, sym->name, (int)minArgs, (int)sym->paramCount, (int)argc); +1 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(buf, sizeof(buf), "", what, sym->name, (int)sym->paramCount, (int)argc); +1 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(name, sizeof(name), "", BAS_TEMP_PREFIX, (int)p->tempCount++); +1 src/apps/kpunch/dvxbasic/compiler/parser.c|plainInt|snprintf(p->error, sizeof(p->error), "", (int)line, msg); +2 src/apps/kpunch/dvxbasic/compiler/strip.c|plainInt|snprintf(cand, sizeof(cand), "", (long)nextMangled++); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|"", ctrlName, (int)index); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|for (int i = 0; i < len; i++) { +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*clearRxBuffer)(int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*close)(int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*getBase)(int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*getIrq)(int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*getPending)(void *); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*getRxBuffered)(int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*getUartType)(int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*handshake)(void *); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*open)(int, int32_t, int, char, int, int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*poll)(void *); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*read)(int, char *, int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*send)(void *, const uint8_t *, int, uint8_t, bool, bool); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*sendBuf)(void *, const uint8_t *, int, uint8_t, bool); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*setBase)(int, int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*setIrq)(int, int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*write)(int, const char *, int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int (*writeBuf)(int, const char *, int); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int rc = sCommApi.send(slot->link, data, len, (uint8_t)slot->termChannel, slot->termEncrypt, false); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int rc = sCommApi.sendBuf(slot->link, (const uint8_t *)data, len, (uint8_t)channel, encrypt != 0); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int rc = sSerApi.open(com - 1, baud, dataBits, parityChar, stopBits, handshake); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int rc = sSerApi.writeBuf(com - 1, data, len); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int rc = sSerApi.writeBuf(sa->com, (const char *)data, len); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|int32_t n = sSerApi.read(com - 1, buf, (int)(sizeof(buf) - 1)); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|snprintf(out, outSize, "", (int)v); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|static void commOnRecv(void *ctx, const uint8_t *data, int len, uint8_t channel) { +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|static void commOnRecv(void *ctx, const uint8_t *data, int len, uint8_t channel); +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|plainInt|void *(*open)(int, int32_t, int, char, int, int, void (*)(void *, const uint8_t *, int, uint8_t), void *); +1 src/apps/kpunch/dvxbasic/ide/ideDesigner.c|plainInt|pos = emitClamped(buf, bufSize, pos, "", pad, (int)ctrl->index); +1 src/apps/kpunch/dvxbasic/ide/ideDesigner.c|plainInt|pos = emitClamped(buf, bufSize, pos, "", pad, ip->name, (int)val); +1 src/apps/kpunch/dvxbasic/ide/ideDesigner.c|plainInt|snprintf(buf, bufSize, "", prefix, (int)(highest + 1)); +1 src/apps/kpunch/dvxbasic/ide/ideDesigner.c|plainInt|snprintf(out, outSize, "", (int)num); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|(int)errLocalLine, vm->errorMsg); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|emitClamped(buf, bufSize, pos, "", (int)arr->totalElements); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|errFile, (int)errLocalLine, msg); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|errFile, (int)errLocalLine, vm->errorMsg); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|pos = emitClamped(buf, bufSize, pos, "", (int)arr->lbound[d], (int)arr->ubound[d]); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|procName, (int)procLine, msg); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|procName, (int)procLine, vm->errorMsg); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, 0, "", (int)IDE_MAX_SOURCE, sProject.files[i].path); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|sOutputLen = emitClamped(sOutputBuf, IDE_MAX_OUTPUT, 0, "", sProject.files[i].path, (int)IDE_MAX_SOURCE); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(bBuf, sizeof(bBuf), "", (int)b); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(buf, bufSize, "", (int)(bufSize - IDE_QUOTE_OVERHEAD), v->strVal->data); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(buf, bufSize, "", (int)v->intVal); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(buf, bufSize, "", (long)v->longVal); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(gBuf, sizeof(gBuf), "", (int)g); +3 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(key, sizeof(key), PREF_KEY_COLOR_FMT, (int)i); +2 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(key, sizeof(key), PREF_KEY_RECENT_FMT, (long)i); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(label, sizeof(label), "", (long)(i + 1), name); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(lineBuf, sizeof(lineBuf), "", (int)sBreakpoints[i].codeLine); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(lineBuf, sizeof(lineBuf), "", (int)sDbgCurrentLine); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(rBuf, sizeof(rBuf), "", (int)r); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(statusBuf, sizeof(statusBuf), "", (int)totalCount); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|snprintf(tabBuf, sizeof(tabBuf), "", (int)prefsGetInt(sPrefs, IDE_PREF_SECTION_EDITOR, IDE_PREF_KEY_TAB_WIDTH, IDE_DEFAULT_TAB_WIDTH)); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|static int cmpEvtPtrs(const void *a, const void *b) { +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|static int cmpEvtPtrs(const void *a, const void *b); +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|static int cmpStrPtrs(const void *a, const void *b) { +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|plainInt|static int cmpStrPtrs(const void *a, const void *b); +1 src/apps/kpunch/dvxbasic/ide/ideMenuEditor.c|plainInt|snprintf(autoName, DSGN_MAX_NAME, MED_SEP_PREFIX "", (int)sepNum); +1 src/apps/kpunch/dvxbasic/ide/ideProject.c|plainInt|snprintf(key, sizeof(key), PRJ_FILE_KEY_FMT, (int)i); +1 src/apps/kpunch/dvxbasic/ide/ideProject.c|plainInt|snprintf(key, sizeof(key), PRJ_FILE_KEY_FMT, (int)idx++); +1 src/apps/kpunch/dvxbasic/ide/ideProject.c|plainInt|snprintf(msg, sizeof(msg), "", PRJ_ICON_SIZE, PRJ_ICON_SIZE, (int)infoW, (int)infoH); +1 src/apps/kpunch/dvxbasic/ide/ideProperties.c|plainInt|snprintf(buf, sizeof(buf), "", (int)*(const int32_t *)((const char *)ctrl + ip->offset)); +1 src/apps/kpunch/dvxbasic/ide/ideProperties.c|plainInt|snprintf(buf, sizeof(buf), "", (int)ctrl->index); +1 src/apps/kpunch/dvxbasic/ide/ideProperties.c|plainInt|snprintf(buf, sizeof(buf), "", ctrl->name, (int)ctrl->index, ctrl->typeName); +1 src/apps/kpunch/dvxbasic/ide/ideProperties.c|plainInt|snprintf(msg, sizeof(msg), "", (int)maxLen); +1 src/apps/kpunch/dvxbasic/ide/ideProperties.c|plainInt|snprintf(newValue, sizeof(newValue), "", (int)intVal); +1 src/apps/kpunch/dvxbasic/ide/ideToolbox.c|plainInt|snprintf(iconResName, sizeof(iconResName), TBX_RES_SUFFIX_FMT, TBX_RES_ICON, (int)pathIdx); +1 src/apps/kpunch/dvxbasic/ide/ideToolbox.c|plainInt|snprintf(nameResName, sizeof(nameResName), TBX_RES_SUFFIX_FMT, TBX_RES_NAME, (int)pathIdx); +1 src/apps/kpunch/dvxbasic/runtime/values.c|plainInt|int32_t len = snprintf(buf, bufSize, "", (int)sigDigits, n); +1 src/apps/kpunch/dvxbasic/runtime/values.c|plainInt|snprintf(buf, sizeof(buf), "", (int)v.intVal); +1 src/apps/kpunch/dvxbasic/runtime/values.c|plainInt|snprintf(buf, sizeof(buf), "", (long)v.longVal); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|for (long i = used; i < recLen; i++) { +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|fseek((FILE *)vm->files[channel].handle, (long)(pos - 1), SEEK_SET); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|long curPos = ftell(fp); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|long endPos = ftell(fp); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|long length = ftell(fp); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|long offset = (vm->files[channel].mode == BAS_FILE_MODE_BINARY) ? (long)(recno - 1) : (long)(recno - 1) * vm->files[channel].recLen; +2 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|long pos = ftell((FILE *)vm->files[channel].handle); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|long recLen = vm->files[channel].recLen; +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|long savedPos = ftell(fp); +3 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|long start = recordBegin(vm, channel, recno); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|long used = ftell(fp) - start; +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|sleep((unsigned int)secs); +2 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|snprintf(buf, sizeof(buf), "", (unsigned int)n); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|snprintf(numBuf, sizeof(numBuf), "", (int)decimals, absN); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|snprintf(sciFmt, sizeof(sciFmt), "", (int)fieldLen, (int)decimals); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|snprintf(vm->errorMsg, sizeof(vm->errorMsg), "", (int)errNum, (int)vm->currentLine, msg); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|snprintf(vm->errorMsg, sizeof(vm->errorMsg), "", (int)errNum, (int)vm->pc, msg); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|snprintf(vm->errorMsg, sizeof(vm->errorMsg), "", (int)module->globalCount, (int)BAS_VM_MAX_GLOBALS); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|srand((unsigned int)n); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|srand((unsigned int)time(NULL)); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|static BasVmResultE recordEnd(BasVmT *vm, int32_t channel, long start, bool writing) { +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|static BasVmResultE recordEnd(BasVmT *vm, int32_t channel, long start, bool writing); +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|static long recordBegin(BasVmT *vm, int32_t channel, int32_t recno) { +1 src/apps/kpunch/dvxbasic/runtime/vm.c|plainInt|static long recordBegin(BasVmT *vm, int32_t channel, int32_t recno); +1 src/apps/kpunch/dvxbasic/stub/bascomp.c|plainInt|fprintf(stderr, "", (int)parser->errorLine, parser->error); +1 src/apps/kpunch/dvxbasic/stub/bascomp.c|plainInt|long endPos = ftell(szf); +1 src/apps/kpunch/dvxbasic/stub/bascomp.c|plainInt|printf("", (int)fileCount); +1 src/apps/kpunch/dvxbasic/stub/bascomp.c|plainInt|printf("", (int)mod->codeLen, (int)mod->procCount, (int)mod->constCount); +1 src/apps/kpunch/dvxbasic/stub/bascomp.c|plainInt|printf("", outputPath, (int)outBytes); +1 src/apps/kpunch/dvxbasic/stub/basrun.c|plainInt|fprintf(stderr, "", (int)errNum, (int)vm->errorLine, basVmGetError(vm)); +1 src/apps/kpunch/dvxbasic/stub/basrun.c|plainInt|fprintf(stderr, "", (int)opt->steps, (int)vm->currentLine); +1 src/apps/kpunch/dvxbasic/stub/basrun.c|plainInt|fprintf(stderr, "", label, (int)parser->errorLine, parser->error); +1 src/apps/kpunch/dvxbasic/stub/basstub.c|plainInt|snprintf(app->name, SHELL_APP_NAME_MAX, "", (int)nameSize, appName); +1 src/apps/kpunch/dvxbasic/stub/basstub.c|plainInt|snprintf(ctx->helpFile, sizeof(ctx->helpFile), "" DVX_PATH_SEP "", ctx->appDir, (int)helpNameSize, helpName); +1 src/apps/kpunch/dvxbasic/stub/basstub.c|plainInt|snprintf(resName, sizeof(resName), BAS_RES_FORM_FMT, (long)i); +1 src/apps/kpunch/dvxbasic/test_compact.c|plainInt|printf("", (int)(sTotal - sFailed), (int)sTotal); +1 src/apps/kpunch/dvxbasic/test_compact.c|plainInt|printf("", (int)compLen, comp); +1 src/apps/kpunch/dvxbasic/test_compact.c|plainInt|printf("", (int)origLen, orig); +1 src/apps/kpunch/dvxbasic/test_compiler.c|plainInt|printf("", (int)gFailCount); +1 src/apps/kpunch/dvxbasic/test_compiler.c|plainInt|printf("", (int)i, mod->procs[i].name, (int)mod->procs[i].paramCount); +1 src/apps/kpunch/dvxbasic/test_compiler.c|plainInt|printf("", (int)mod->procCount); +1 src/apps/kpunch/dvxbasic/test_compiler.c|plainInt|printf("", (int)mod->procCount, (int)mod->codeLen); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|n += snprintf(detail + n, sizeof(detail) - n, "", (int)parser.cg.codeLen); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|printf("", (int)sPassCount, (int)sFailCount); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|snprintf(detail, sizeof(detail), "", (int)first, (int)second, cap.buf); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|snprintf(detail, sizeof(detail), "", (int)i, basTokenName(expected[i].type), expected[i].text, basTokenName(lex.token.type), lex.token.text); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|snprintf(detail, sizeof(detail), "", (int)rc, cap.buf, expected); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|snprintf(detail, sizeof(detail), "", (int)rc, err); +2 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|snprintf(detail, sizeof(detail), "", (int)rc, out); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|snprintf(detail, sizeof(detail), "", (int)result, basVmGetError(vm), cap.buf); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|snprintf(detail, sizeof(detail), "", (int)sawHandlerConst, (int)sawTextConst); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|snprintf(line, sizeof(line), "", uiStubFormName(ctrlRef), methodName, (int)argc); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|snprintf(path, sizeof(path), "", uiStubFormName(formRef), ctrlName, (int)index); +1 src/apps/kpunch/dvxbasic/test_suite.c|plainInt|used += snprintf(src + used, TOO_MANY_GLOBALS_SRC_MAX - used, "", (int)i); +1 src/apps/kpunch/dvxhelp/dvxhelp.c|plainInt|fseek(sHlpFile, -(long)sizeof(HlpHeaderT), SEEK_END); +1 src/apps/kpunch/dvxhelp/dvxhelp.c|plainInt|long savedPos = ftell(sHlpFile); +1 src/apps/kpunch/progman/progman.c|plainInt|dvxLog("", (long)sAppCount); +1 src/apps/kpunch/progman/progman.c|plainInt|qsort(sAppFiles, sAppCount, sizeof(AppEntryT), (int (*)(const void *, const void *))appEntryCmpName); +1 src/apps/kpunch/progman/progman.c|plainInt|snprintf(buf, sizeof(buf), "", (long)count); +1 src/libs/kpunch/dvxshell/shellApp.c|plainInt|dvxLog("", app->name, (long)entryResult); +1 src/libs/kpunch/dvxshell/shellApp.c|plainInt|dvxLog("", app->name, (long)id, app->hasMainLoop ? "" : "", (unsigned long)entry, (unsigned long)desc); +1 src/libs/kpunch/dvxshell/shellApp.c|plainInt|snprintf(out, outSize, "" DVX_PATH_SEP "", (long)id, dot); +1 src/libs/kpunch/dvxshell/shellApp.c|plainInt|snprintf(out, outSize, "" DVX_PATH_SEP "", tmpDir, (long)id, dot); +2 src/libs/kpunch/dvxshell/shellMain.c|plainInt|dvxLog("", (long)crashedAppId); +1 src/libs/kpunch/dvxshell/shellMain.c|plainInt|dvxLog("", (long)result); +1 src/libs/kpunch/dvxshell/shellMain.c|plainInt|dvxLog("", (long)sCtx.display.width, (long)sCtx.display.height, (long)sCtx.display.format.bitsPerPixel, (long)sCtx.display.pitch); +1 src/libs/kpunch/dvxshell/shellMain.c|plainInt|dvxLog("", (long)videoW, (long)videoH, (long)videoBpp); +1 src/libs/kpunch/dvxshell/shellMain.c|plainInt|dvxLog("", (long)w, (long)h, (long)bpp); +1 src/libs/kpunch/dvxshell/shellMain.c|plainInt|dvxLog("", (unsigned long)crashedApp->mainTaskId); +1 src/libs/kpunch/dvxshell/shellMain.c|plainInt|dvxLog("", wheelStr, (long)dblClick, accelStr, (long)speed, (long)wheelStep); +1 src/libs/kpunch/dvxshell/shellMain.c|plainInt|static volatile int sCrashSignal = 0; +1 src/libs/kpunch/libdvx/dvxApp.c|plainInt|int b; +4 src/libs/kpunch/libdvx/dvxApp.c|plainInt|int channels; +1 src/libs/kpunch/libdvx/dvxApp.c|plainInt|int comp = 0; +1 src/libs/kpunch/libdvx/dvxApp.c|plainInt|int g; +1 src/libs/kpunch/libdvx/dvxApp.c|plainInt|int h = 0; +4 src/libs/kpunch/libdvx/dvxApp.c|plainInt|int imgH; +4 src/libs/kpunch/libdvx/dvxApp.c|plainInt|int imgW; +1 src/libs/kpunch/libdvx/dvxApp.c|plainInt|int r; +1 src/libs/kpunch/libdvx/dvxApp.c|plainInt|int w = 0; +1 src/libs/kpunch/libdvx/dvxDialog.c|plainInt|static int fdEntryCompare(const void *a, const void *b) { +1 src/libs/kpunch/libdvx/dvxDialog.c|plainInt|static int fdEntryCompare(const void *a, const void *b); +1 src/libs/kpunch/libdvx/dvxPrefs.c|plainInt|int ch; +1 src/libs/kpunch/libdvx/dvxPrefs.c|plainInt|int d = tolower((unsigned char)*a) - tolower((unsigned char)*b); +1 src/libs/kpunch/libdvx/dvxPrefs.c|plainInt|long n = strtol(val, &end, 10); +1 src/libs/kpunch/libdvx/dvxPrefs.c|plainInt|snprintf(buf, sizeof(buf), "", (long)value); +1 src/libs/kpunch/libdvx/dvxPrefs.c|plainInt|static int strcmpci(const char *a, const char *b) { +1 src/libs/kpunch/libdvx/dvxPrefs.c|plainInt|static int strcmpci(const char *a, const char *b); +1 src/libs/kpunch/libdvx/dvxResource.c|plainInt|long dxeSize = dvxResDxeContentSize(path); +1 src/libs/kpunch/libdvx/dvxResource.c|plainInt|static int dvxResEntryCmp(const void *a, const void *b) { +1 src/libs/kpunch/libdvx/dvxWm.c|plainInt|dvxLog("", (long)bufSize); +1 src/libs/kpunch/libdvx/dvxWm.c|plainInt|int channels; +1 src/libs/kpunch/libdvx/dvxWm.c|plainInt|int imgH; +1 src/libs/kpunch/libdvx/dvxWm.c|plainInt|int imgW; +1 src/libs/kpunch/libdvx/platform/dvxPlat.h|plainInt|void platformInstallCrashHandler(jmp_buf *recoveryBuf, volatile int *crashSignal, PlatformLogFnT logFn); +1 src/libs/kpunch/libdvx/platform/dvxPlat.h|plainInt|void platformLogCrashDetail(int sig, PlatformLogFnT logFn); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|codeRegion.address = codeAddr + (unsigned long)int9Handler; +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|dataRegion.address = dataAddr + (unsigned long)&sKeyUp; +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern double __floatdidf(long long); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern float __floatdisf(long long); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern long long __divdi3(long long, long long); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern long long __fixdfdi(double); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern long long __fixsfdi(float); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern long long __moddi3(long long, long long); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern long long __muldi3(long long, long long); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern unsigned long long __fixunsdfdi(double); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern unsigned long long __fixunssfdi(float); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern unsigned long long __udivdi3(unsigned long long, unsigned long long); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern unsigned long long __udivmoddi4(unsigned long long, unsigned long long, unsigned long long *); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern unsigned long long __umoddi3(unsigned long long, unsigned long long); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern unsigned short __dj_ctype_flags[]; +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern void *stbds_hmdel_key(void *a, size_t elemsize, void *key, size_t keysize, size_t keyoffset, int mode); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern void *stbds_hmget_key(void *a, size_t elemsize, void *key, size_t keysize, int mode); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern void *stbds_hmget_key_ts(void *a, size_t elemsize, void *key, size_t keysize, ptrdiff_t *temp, int mode); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern void *stbds_hmput_key(void *a, size_t elemsize, void *key, size_t keysize, int mode); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern void *stbds_shmode_func(size_t elemsize, int mode); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|extern void __dj_assert(const char *, const char *, int); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|fprintf(stderr, "", (long)requestedW, (long)requestedH); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|fprintf(stderr, "", (unsigned long)fbSize); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|fprintf(stderr, "", (unsigned long)physAddr); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|region.address = codeAddr + (unsigned long)int9Handler; +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|region.address = dataAddr + (unsigned long)&sKeyUp; +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sNewInt9.pm_offset = (unsigned long)int9Handler; +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|static void platformCrashHandler(int sig) { +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|static void platformCrashHandler(int sig); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|static volatile int *sPlatformCrashSignal = NULL; +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (long)(r.x.ax & 0xFF), (long)((r.x.ax >> 8) & 0xFF)); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (long)display->format.bitsPerPixel); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (long)display->format.blueBits, (long)display->format.blueShift); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (long)display->format.greenBits, (long)display->format.greenShift); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (long)display->format.redBits, (long)display->format.redShift); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (long)display->pitch); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (long)display->width, (long)display->height); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (long)family, (long)model, (long)stepping); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (long)r.x.bx); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (unsigned long)(fbSize / 1024)); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (unsigned long)(totalMem64k * 64)); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (unsigned long)freeKb, (unsigned long)(freeKb / 1024)); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (unsigned long)freeLinearKb); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (unsigned long)largestKb, (unsigned long)(largestKb / 1024)); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (unsigned long)linearKb, (unsigned long)(linearKb / 1024)); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (unsigned long)mhz); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (unsigned long)pagingKb, (unsigned long)(pagingKb / 1024)); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", (unsigned long)totalKb, (unsigned long)(totalKb / 1024)); +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|sysInfoAppend("", letter, driveType, (unsigned long)totalMb, (unsigned long)freeMb); +2 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|unsigned long codeAddr; +2 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|unsigned long dataAddr; +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|void platformInstallCrashHandler(jmp_buf *recoveryBuf, volatile int *crashSignal, PlatformLogFnT logFn) { +1 src/libs/kpunch/libdvx/platform/dvxPlatformDos.c|plainInt|void platformLogCrashDetail(int sig, PlatformLogFnT logFn) { +1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|plainInt|(long)display->width, (long)display->height, (long)display->format.bitsPerPixel); +1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|plainInt|(unsigned long)totalKb, (unsigned long)freeKb, +1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|plainInt|bool ok = stbi_write_png(path, d->width, d->height, PNG_COMPONENTS, rgb, (int)rowBytes) != 0; +1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|plainInt|memset(dst, (int)(color & 0xFF), (size_t)count); +1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|plainInt|static void crashHandler(int sig) { +1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|plainInt|static void crashHandler(int sig); +1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|plainInt|static volatile int *sCrashSignal = NULL; +1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|plainInt|void platformInstallCrashHandler(jmp_buf *recoveryBuf, volatile int *crashSignal, PlatformLogFnT logFn) { +1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|plainInt|void platformLogCrashDetail(int sig, PlatformLogFnT logFn) { +1 src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c|plainInt|int drive = toupper((unsigned char)path[0]) - ''; +1 src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c|plainInt|long size = ftell(f); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|PktConnT *pktOpen(int com, int windowSize, PktRecvCallbackT callback, void *callbackCtx) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|PktConnT *pktOpen(int com, int windowSize, PktRecvCallbackT callback, void *callbackCtx); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|for (int i = 0; i < conn->txCount; i++) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|for (int i = 0; i < len; i++) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|for (int i = 0; i < nRead; i++) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|for (int i = 0; i < rawLen; i++) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|for (int i = idx; i < conn->txCount; i++) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int com; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int delivered = 0; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int frameLen = conn->rxFrameLen; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int idx = txSlotIndex(conn, seq); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int len; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int nRead; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int out; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int payloadLen; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int pktGetPending(PktConnT *conn) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int pktGetPending(PktConnT *conn); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int pktPoll(PktConnT *conn) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int pktPoll(PktConnT *conn); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int pktReset(PktConnT *conn) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int pktReset(PktConnT *conn); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int pktSend(PktConnT *conn, const uint8_t *data, int len, bool block) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int pktSend(PktConnT *conn, const uint8_t *data, int len, bool block); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int rawLen; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int rxFrameLen; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int txCount; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int txHead; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|int windowSize; +1 src/libs/kpunch/serial/packet/packet.c|plainInt|static bool processFrame(PktConnT *conn, const uint8_t *frame, int len) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|static bool processFrame(PktConnT *conn, const uint8_t *frame, int len); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|static int seqInWindow(uint8_t seq, uint8_t base, int size) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|static int seqInWindow(uint8_t seq, uint8_t base, int size); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|static int txSlotIndex(PktConnT *conn, uint8_t seq) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|static int txSlotIndex(PktConnT *conn, uint8_t seq); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|static uint16_t crcCalc(const uint8_t *data, int len) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|static uint16_t crcCalc(const uint8_t *data, int len); +1 src/libs/kpunch/serial/packet/packet.c|plainInt|static void sendFrame(PktConnT *conn, uint8_t seq, uint8_t type, const uint8_t *payload, int len) { +1 src/libs/kpunch/serial/packet/packet.c|plainInt|static void sendFrame(PktConnT *conn, uint8_t seq, uint8_t type, const uint8_t *payload, int len); +1 src/libs/kpunch/serial/packet/packet.h|plainInt|PktConnT *pktOpen(int com, int windowSize, PktRecvCallbackT callback, void *callbackCtx); +1 src/libs/kpunch/serial/packet/packet.h|plainInt|int pktGetPending(PktConnT *conn); +1 src/libs/kpunch/serial/packet/packet.h|plainInt|int pktPoll(PktConnT *conn); +1 src/libs/kpunch/serial/packet/packet.h|plainInt|int pktReset(PktConnT *conn); +1 src/libs/kpunch/serial/packet/packet.h|plainInt|int pktSend(PktConnT *conn, const uint8_t *data, int len, bool block); +1 src/libs/kpunch/serial/packet/packet.h|plainInt|typedef void (*PktRecvCallbackT)(void *ctx, const uint8_t *data, int len); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|char rs232GetParity(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|char rs232GetParity(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|codeRegion.address = codeAddr + (unsigned long)comGeneralIsr; +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|dataRegion.address = dataAddr + (unsigned long)sComPorts; +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|for (int i = 0; i < FIFO_DEFAULT_THRESHOLD; i++) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|for (int i = 0; i < FIFO_SIZE; i++) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int cnt; +2 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int count; +3 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int i; +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rc = RS232_SUCCESS; +2 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rc; +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232ClearRxBuffer(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232ClearRxBuffer(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232ClearTxBuffer(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232ClearTxBuffer(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232Close(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232Close(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetBase(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetBase(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetCts(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetCts(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetData(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetData(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetDsr(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetDsr(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetDtr(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetDtr(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetHandshake(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetHandshake(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetIrq(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetIrq(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetLsr(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetLsr(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetMcr(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetMcr(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetMsr(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetMsr(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetRts(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetRts(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetRxBuffered(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetRxBuffered(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetStop(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetStop(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetTxBuffered(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetTxBuffered(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetUartType(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232GetUartType(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232Read(int com, char *data, int len) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232Read(int com, char *data, int len); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232Set(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232Set(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetBase(int com, int base) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetBase(int com, int base); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetBps(int com, int32_t bps) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetBps(int com, int32_t bps); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetData(int com, int dataBits) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetData(int com, int dataBits); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetDtr(int com, bool dtr) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetDtr(int com, bool dtr); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetFifoThreshold(int com, int threshold) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetFifoThreshold(int com, int threshold); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetHandshake(int com, int handshake) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetHandshake(int com, int handshake); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetIrq(int com, int irq) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetIrq(int com, int irq); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetMcr(int com, int mcr) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetMcr(int com, int mcr); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetParity(int com, char parity) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetParity(int com, char parity); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetRts(int com, bool rts) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetRts(int com, bool rts); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetStop(int com, int stopBits) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232SetStop(int com, int stopBits); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232Write(int com, const char *data, int len) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232Write(int com, const char *data, int len); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232WriteBuf(int com, const char *data, int len) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int rs232WriteBuf(int com, const char *data, int len); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int32_t rs232GetBps(int com) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|int32_t rs232GetBps(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|region.address = baseAddr + (unsigned long)comGeneralIsr; +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|region.address = baseAddr + (unsigned long)sComPorts; +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|sIsrAddr.pm_offset = (unsigned long)comGeneralIsr; +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static int dpmiLockMemory(void) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static int dpmiLockMemory(void); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static int findIrq(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static int findIrq(int comport) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static int installIrqHandler(int irq) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static int installIrqHandler(int irq); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static void dpmiGetPvect(int vector, _go32_dpmi_seginfo *info) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static void dpmiGetPvect(int vector, _go32_dpmi_seginfo *info); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static void dpmiSetPvect(int vector, _go32_dpmi_seginfo *info) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static void dpmiSetPvect(int vector, _go32_dpmi_seginfo *info); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static void freeIrq(int com); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static void freeIrq(int comport) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static void removeIrqHandler(int irq) { +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|static void removeIrqHandler(int irq); +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|unsigned long baseAddr; +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|unsigned long codeAddr; +1 src/libs/kpunch/serial/rs232/rs232.c|plainInt|unsigned long dataAddr; +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|char rs232GetParity(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232ClearRxBuffer(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232ClearTxBuffer(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232Close(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetBase(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetCts(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetData(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetDsr(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetDtr(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetHandshake(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetIrq(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetLsr(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetMcr(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetMsr(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetRts(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetRxBuffered(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetStop(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetTxBuffered(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232GetUartType(int com); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232Read(int com, char *data, int len); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232Set(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetBase(int com, int base); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetBps(int com, int32_t bps); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetData(int com, int dataBits); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetDtr(int com, bool dtr); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetFifoThreshold(int com, int threshold); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetHandshake(int com, int handshake); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetIrq(int com, int irq); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetMcr(int com, int mcr); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetParity(int com, char parity); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetRts(int com, bool rts); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232SetStop(int com, int stopBits); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232Write(int com, const char *data, int len); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int rs232WriteBuf(int com, const char *data, int len); +1 src/libs/kpunch/serial/rs232/rs232.h|plainInt|int32_t rs232GetBps(int com); +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|SecLinkT *secLinkOpen(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake, SecLinkRecvT callback, void *ctx) { +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|SecLinkT *secLinkOpen(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake, SecLinkRecvT callback, void *ctx); +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|for (int i = 0; i < SEC_XTEA_KEY_SIZE; i++) { +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|if (payloadLen > (int)sizeof(plaintext)) { +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int chunk = len - offset; +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int com; +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int len; +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int offset = 0; +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int payloadLen = len - 1; +5 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int rc; +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int secLinkGetPending(SecLinkT *link) { +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int secLinkGetPending(SecLinkT *link); +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int secLinkHandshake(SecLinkT *link) { +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int secLinkHandshake(SecLinkT *link); +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int secLinkPoll(SecLinkT *link) { +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int secLinkPoll(SecLinkT *link); +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int secLinkSend(SecLinkT *link, const uint8_t *data, int len, uint8_t channel, bool encrypt, bool block) { +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int secLinkSend(SecLinkT *link, const uint8_t *data, int len, uint8_t channel, bool encrypt, bool block); +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int secLinkSendBuf(SecLinkT *link, const uint8_t *data, int len, uint8_t channel, bool encrypt) { +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int secLinkSendBuf(SecLinkT *link, const uint8_t *data, int len, uint8_t channel, bool encrypt); +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|int state; +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|payloadLen = (int)sizeof(plaintext); +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|static void internalRecv(void *ctx, const uint8_t *data, int len) { +1 src/libs/kpunch/serial/seclink/secLink.c|plainInt|static void internalRecv(void *ctx, const uint8_t *data, int len); +1 src/libs/kpunch/serial/seclink/secLink.h|plainInt|SecLinkT *secLinkOpen(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake, SecLinkRecvT callback, void *ctx); +1 src/libs/kpunch/serial/seclink/secLink.h|plainInt|int secLinkGetPending(SecLinkT *link); +1 src/libs/kpunch/serial/seclink/secLink.h|plainInt|int secLinkHandshake(SecLinkT *link); +1 src/libs/kpunch/serial/seclink/secLink.h|plainInt|int secLinkPoll(SecLinkT *link); +1 src/libs/kpunch/serial/seclink/secLink.h|plainInt|int secLinkSend(SecLinkT *link, const uint8_t *data, int len, uint8_t channel, bool encrypt, bool block); +1 src/libs/kpunch/serial/seclink/secLink.h|plainInt|int secLinkSendBuf(SecLinkT *link, const uint8_t *data, int len, uint8_t channel, bool encrypt); +1 src/libs/kpunch/serial/seclink/secLink.h|plainInt|typedef void (*SecLinkRecvT)(void *ctx, const uint8_t *data, int len, uint8_t channel); +1 src/libs/kpunch/serial/security/security.c|plainInt|for (int i = 0; i < BN_BYTES; i++) { +5 src/libs/kpunch/serial/security/security.c|plainInt|for (int i = 0; i < BN_WORDS; i++) { +1 src/libs/kpunch/serial/security/security.c|plainInt|for (int i = 0; i < M0INV_NEWTON_ITERS; i++) { +1 src/libs/kpunch/serial/security/security.c|plainInt|for (int i = 0; i < R2_DOUBLE_ITERATIONS; i++) { +1 src/libs/kpunch/serial/security/security.c|plainInt|for (int i = 0; i < XTEA_ROUNDS; i++) { +3 src/libs/kpunch/serial/security/security.c|plainInt|for (int i = 0; i < len; i++) { +2 src/libs/kpunch/serial/security/security.c|plainInt|for (int i = BN_WORDS - 1; i >= 0; i--) { +1 src/libs/kpunch/serial/security/security.c|plainInt|for (int i = bits - 1; i >= 0; i--) { +1 src/libs/kpunch/serial/security/security.c|plainInt|for (int j = 0; j < BN_WORDS; j++) { +1 src/libs/kpunch/serial/security/security.c|plainInt|for (int j = 1; j < BN_WORDS; j++) { +1 src/libs/kpunch/serial/security/security.c|plainInt|int bits = i * 32; +1 src/libs/kpunch/serial/security/security.c|plainInt|int bits; +1 src/libs/kpunch/serial/security/security.c|plainInt|int carry = bnShiftLeft1(r2); +1 src/libs/kpunch/serial/security/security.c|plainInt|int got = secRngGatherEntropy(entropy, sizeof(entropy)); +2 src/libs/kpunch/serial/security/security.c|plainInt|int j = (BN_WORDS - 1 - i) * 4; +1 src/libs/kpunch/serial/security/security.c|plainInt|int out = 0; +1 src/libs/kpunch/serial/security/security.c|plainInt|int secDhComputeSecret(SecDhT *dh, const uint8_t *remotePub, int len) { +1 src/libs/kpunch/serial/security/security.c|plainInt|int secDhComputeSecret(SecDhT *dh, const uint8_t *remotePub, int len); +1 src/libs/kpunch/serial/security/security.c|plainInt|int secDhDeriveKey(SecDhT *dh, uint8_t *key, int keyLen) { +1 src/libs/kpunch/serial/security/security.c|plainInt|int secDhDeriveKey(SecDhT *dh, uint8_t *key, int keyLen); +1 src/libs/kpunch/serial/security/security.c|plainInt|int secDhGenerateKeys(SecDhT *dh) { +1 src/libs/kpunch/serial/security/security.c|plainInt|int secDhGenerateKeys(SecDhT *dh); +1 src/libs/kpunch/serial/security/security.c|plainInt|int secDhGetPublicKey(SecDhT *dh, uint8_t *buf, int *len) { +1 src/libs/kpunch/serial/security/security.c|plainInt|int secDhGetPublicKey(SecDhT *dh, uint8_t *buf, int *len); +1 src/libs/kpunch/serial/security/security.c|plainInt|int secRngGatherEntropy(uint8_t *buf, int len) { +1 src/libs/kpunch/serial/security/security.c|plainInt|int secRngGatherEntropy(uint8_t *buf, int len); +1 src/libs/kpunch/serial/security/security.c|plainInt|return (int)borrow; +1 src/libs/kpunch/serial/security/security.c|plainInt|static int bnBit(const BigNumT *a, int n) { +1 src/libs/kpunch/serial/security/security.c|plainInt|static int bnBit(const BigNumT *a, int n); +1 src/libs/kpunch/serial/security/security.c|plainInt|static int bnBitLength(const BigNumT *a) { +1 src/libs/kpunch/serial/security/security.c|plainInt|static int bnBitLength(const BigNumT *a); +1 src/libs/kpunch/serial/security/security.c|plainInt|static int bnCmp(const BigNumT *a, const BigNumT *b) { +1 src/libs/kpunch/serial/security/security.c|plainInt|static int bnCmp(const BigNumT *a, const BigNumT *b); +1 src/libs/kpunch/serial/security/security.c|plainInt|static int bnShiftLeft1(BigNumT *a) { +1 src/libs/kpunch/serial/security/security.c|plainInt|static int bnShiftLeft1(BigNumT *a); +1 src/libs/kpunch/serial/security/security.c|plainInt|static int bnSub(BigNumT *result, const BigNumT *a, const BigNumT *b) { +1 src/libs/kpunch/serial/security/security.c|plainInt|static int bnSub(BigNumT *result, const BigNumT *a, const BigNumT *b); +1 src/libs/kpunch/serial/security/security.c|plainInt|static void secureZero(void *ptr, int len) { +1 src/libs/kpunch/serial/security/security.c|plainInt|static void secureZero(void *ptr, int len); +1 src/libs/kpunch/serial/security/security.c|plainInt|void secCipherCrypt(SecCipherT *c, uint8_t *data, int len) { +1 src/libs/kpunch/serial/security/security.c|plainInt|void secCipherCrypt(SecCipherT *c, uint8_t *data, int len); +1 src/libs/kpunch/serial/security/security.c|plainInt|void secRngAddEntropy(const uint8_t *data, int len) { +1 src/libs/kpunch/serial/security/security.c|plainInt|void secRngAddEntropy(const uint8_t *data, int len); +1 src/libs/kpunch/serial/security/security.c|plainInt|void secRngBytes(uint8_t *buf, int len) { +1 src/libs/kpunch/serial/security/security.c|plainInt|void secRngBytes(uint8_t *buf, int len); +1 src/libs/kpunch/serial/security/security.c|plainInt|void secRngSeed(const uint8_t *entropy, int len) { +1 src/libs/kpunch/serial/security/security.c|plainInt|void secRngSeed(const uint8_t *entropy, int len); +1 src/libs/kpunch/serial/security/security.h|plainInt|int secDhComputeSecret(SecDhT *dh, const uint8_t *remotePub, int len); +1 src/libs/kpunch/serial/security/security.h|plainInt|int secDhDeriveKey(SecDhT *dh, uint8_t *key, int keyLen); +1 src/libs/kpunch/serial/security/security.h|plainInt|int secDhGenerateKeys(SecDhT *dh); +1 src/libs/kpunch/serial/security/security.h|plainInt|int secDhGetPublicKey(SecDhT *dh, uint8_t *buf, int *len); +1 src/libs/kpunch/serial/security/security.h|plainInt|int secRngGatherEntropy(uint8_t *buf, int len); +1 src/libs/kpunch/serial/security/security.h|plainInt|void secCipherCrypt(SecCipherT *c, uint8_t *data, int len); +1 src/libs/kpunch/serial/security/security.h|plainInt|void secRngAddEntropy(const uint8_t *data, int len); +1 src/libs/kpunch/serial/security/security.h|plainInt|void secRngBytes(uint8_t *buf, int len); +1 src/libs/kpunch/serial/security/security.h|plainInt|void secRngSeed(const uint8_t *entropy, int len); +1 src/libs/kpunch/sql/dvxSql.c|plainInt|int rc = sqlite3_exec(entry->db, sql, NULL, NULL, &errMsg); +1 src/libs/kpunch/sql/dvxSql.c|plainInt|int rc = sqlite3_open(path, &sDbs[slot].db); +1 src/libs/kpunch/sql/dvxSql.c|plainInt|int rc = sqlite3_prepare_v2(entry->db, sql, -1, &stmt, NULL); +1 src/libs/kpunch/sql/dvxSql.c|plainInt|int rc = sqlite3_step(cur->stmt); +1 src/libs/kpunch/taskmgr/shellTaskMgr.c|plainInt|int32_t pos = snprintf(buf, sizeof(buf), "", (long)count); +1 src/libs/kpunch/taskmgr/shellTaskMgr.c|plainInt|snprintf(buf + pos, sizeof(buf) - pos, "", (unsigned long)(usedKb / 1024), (unsigned long)(totalKb / 1024)); +1 src/libs/kpunch/taskmgr/shellTaskMgr.c|plainInt|snprintf(row.mem, sizeof(row.mem), "", (unsigned long)(memKb / 1024)); +1 src/libs/kpunch/taskmgr/shellTaskMgr.c|plainInt|snprintf(row.mem, sizeof(row.mem), "", (unsigned long)memKb); +1 src/loader/loaderMain.c|plainInt|(int)sLibCount, (int)sWidgetCount); +1 src/loader/loaderMain.c|plainInt|(int)storedLibs, (int)sLibCount, (int)storedWidgets, (int)sWidgetCount); +1 src/loader/loaderMain.c|plainInt|dvxLog("", (int)rc); +1 src/loader/loaderMain.c|plainInt|dvxLog("", hcfPath, ctx.outputFile, (int)inputCount); +1 src/loader/loaderMain.c|plainInt|fprintf(stderr, "", (int)(total - loaded), (int)total); +1 src/loader/loaderMain.c|plainInt|fprintf(stderr, "", (int)errors); +1 src/loader/loaderMain.c|plainInt|int result = shellMain(argc, argv); +1 src/loader/loaderMain.c|plainInt|typedef int (*ShellMainFnT)(int, char **); +1 src/loader/loaderMain.c|plainInt|while (fgets(line, (int)sizeof(line), f)) { +1 src/loader/stddclmr.h|blockComment|/* +1 src/test/basic/fuzzCommon.c|plainInt|int system(const char *command) { +1 src/test/basic/fuzzCommon.c|plainInt|int system(const char *command); +1 src/test/basic/fuzzCommon.c|plainInt|long size = ftell(f); +1 src/test/basic/fuzzCommon.c|plainInt|static int seedCompare(const void *a, const void *b) { +1 src/test/basic/fuzzCommon.c|plainInt|static int seedCompare(const void *a, const void *b); +1 src/test/basic/fuzzCommon.c|plainInt|unsigned int sleep(unsigned int seconds) { +1 src/test/basic/fuzzCommon.c|plainInt|unsigned int sleep(unsigned int seconds); +1 src/test/basic/fuzzCompile.c|plainInt|printf("", (int)mutants, (int)compiled, (int)stats.halted, (int)stats.errors, (int)stats.stepCaps, (int)skipped); +1 src/test/basic/fuzzCompile.c|plainInt|printf("", (int)seedCount, (int)mutations, (unsigned long long)seed, (int)stepCap); +1 src/test/basic/fuzzCompile.c|plainInt|snprintf(path, sizeof(path), "", saveDir, seeds[s].name, (int)m); +1 src/test/basic/fuzzModule.c|plainInt|extern int __lsan_do_recoverable_leak_check(void) __attribute__((weak)); +1 src/test/basic/fuzzModule.c|plainInt|printf("", (int)blobCount, (int)mutations, (unsigned long long)seed, (int)stepCap); +1 src/test/basic/fuzzModule.c|plainInt|printf("", (int)mutants, (int)deser.rejected, (int)deser.accepted, (int)run.halted, (int)run.errors, (int)run.stepCaps); +1 src/test/basic/fuzzModule.c|plainInt|printf("", blob->name, (int)m); +1 src/test/basic/fuzzModule.c|plainInt|printf("", path, h->name, (int)h->mutation); +1 src/test/basic/fuzzModule.c|plainInt|snprintf(path, sizeof(path), "", dumpDir, (int)mutants); +1 src/test/basic/fuzzModule.c|plainInt|snprintf(path, sizeof(path), FUZZ_LEAK_FILE, (int)i); +1 src/test/dialogs/dialogsTest.c|plainInt|printf("", (long)loaded); +1 src/test/dvxtest/dvxtest.c|plainInt|fprintf(stderr, "", (long long)expected, (long long)actual); +1 src/test/dvxtest/dvxtest.c|plainInt|fprintf(stderr, "", (long)badX, (long)badY); +1 src/test/dvxtest/dvxtest.c|plainInt|fprintf(stderr, "", (long)gw, (long)gh, (long)w, (long)h); +1 src/test/dvxtest/dvxtest.c|plainInt|fprintf(stderr, "", (long)w, (long)h, (long)bpp); +1 src/test/dvxtest/dvxtest.c|plainInt|fprintf(stderr, "", (unsigned long)hash, actualPath); +1 src/test/dvxtest/dvxtest.c|plainInt|fprintf(stderr, "", file, (long)line, sSuiteName, sCaseName, expr); +1 src/test/dvxtest/dvxtest.c|plainInt|printf("", sSuiteName, (long)sPassCount, (long)sFailCount); +1 src/test/dvxtest/dvxtest.c|plainInt|static int32_t removeTempEntry(const char *path, const struct stat *st, int typeflag, struct FTW *ftw) { +1 src/test/dvxtest/dvxtest.c|plainInt|static int32_t removeTempEntry(const char *path, const struct stat *st, int typeflag, struct FTW *ftw); +1 src/test/dvxtest/selfTest.c|plainInt|printf("", (long)loaded); +1 src/test/formrt/frtHarness.c|plainInt|pos = emitLine(buf, bufSize, pos, indent + 1, "", (int)ctrl->index); +1 src/test/formrt/frtHarness.c|plainInt|pos = emitLine(buf, bufSize, pos, indent, "", name, (int)basValToNumber(value)); +1 src/test/formrt/testFormRt.c|plainInt|printf("", (int)tested); +2 src/test/formrt/testFormRt.c|plainInt|snprintf(expect, sizeof(expect), "", (int)(lbl->widget->w / 2), (int)(lbl->widget->h / 2)); +1 src/test/host/smoke.c|plainInt|int status = 0; +1 src/test/host/smoke.c|plainInt|printf("", (long)loaded); +1 src/test/host/smoke.c|plainInt|printf("", (long)sClickCount, argv[2]); +1 src/test/ide/testIde.c|plainInt|printf("", pathA, (long)lenA, pathB, (long)lenB); +1 src/test/ide/testIde.c|plainInt|snprintf(expected, sizeof(expected), "", (int)IDE_MAX_TAB_WIDTH); +2 src/test/serial/loopShim.c|plainInt|for (int com = 0; com < RS232_NUM_PORTS; com++) { +2 src/test/serial/loopShim.c|plainInt|for (int i = 0; i < HOLD_SIZE; i++) { +1 src/test/serial/loopShim.c|plainInt|for (int i = 0; i < len; i++) { +3 src/test/serial/loopShim.c|plainInt|for (int k = 0; k < LOOP_FAULT_COUNT; k++) { +1 src/test/serial/loopShim.c|plainInt|int loopPeer(int com) { +1 src/test/serial/loopShim.c|plainInt|int n = 0; +1 src/test/serial/loopShim.c|plainInt|int rs232Close(int com) { +1 src/test/serial/loopShim.c|plainInt|int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake) { +1 src/test/serial/loopShim.c|plainInt|int rs232Read(int com, char *data, int len) { +1 src/test/serial/loopShim.c|plainInt|int rs232Write(int com, const char *data, int len) { +1 src/test/serial/loopShim.c|plainInt|static bool portValid(int com) { +1 src/test/serial/loopShim.c|plainInt|static bool portValid(int com); +1 src/test/serial/loopShim.c|plainInt|static void releaseHolds(int com, bool afterByte) { +1 src/test/serial/loopShim.c|plainInt|static void releaseHolds(int com, bool afterByte); +1 src/test/serial/loopShim.c|plainInt|static void transmitByte(int com, uint8_t byte, uint32_t forced) { +1 src/test/serial/loopShim.c|plainInt|static void transmitByte(int com, uint8_t byte, uint32_t forced); +1 src/test/serial/loopShim.c|plainInt|void loopGetStats(int com, LoopStatsT *stats) { +1 src/test/serial/loopShim.c|plainInt|void loopSetFault(int com, LoopFaultE kind, uint32_t rate, uint32_t seed) { +1 src/test/serial/loopShim.c|plainInt|void loopSetFaultParam(int com, LoopFaultE kind, uint32_t param) { +1 src/test/serial/loopShim.c|plainInt|void loopSetFaultWindow(int com, uint32_t firstByte, uint32_t count) { +1 src/test/serial/loopShim.c|plainInt|void loopSetScope(int com, LoopScopeE scope) { +1 src/test/serial/loopShim.h|plainInt|int loopPeer(int com); +1 src/test/serial/loopShim.h|plainInt|int rs232Close(int com); +1 src/test/serial/loopShim.h|plainInt|int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake); +1 src/test/serial/loopShim.h|plainInt|int rs232Read(int com, char *data, int len); +1 src/test/serial/loopShim.h|plainInt|int rs232Write(int com, const char *data, int len); +1 src/test/serial/loopShim.h|plainInt|void loopGetStats(int com, LoopStatsT *stats); +1 src/test/serial/loopShim.h|plainInt|void loopSetFault(int com, LoopFaultE kind, uint32_t rate, uint32_t seed); +1 src/test/serial/loopShim.h|plainInt|void loopSetFaultParam(int com, LoopFaultE kind, uint32_t param); +1 src/test/serial/loopShim.h|plainInt|void loopSetFaultWindow(int com, uint32_t firstByte, uint32_t count); +1 src/test/serial/loopShim.h|plainInt|void loopSetScope(int com, LoopScopeE scope); +1 src/test/serial/testSerial.c|plainInt|TEST_EQ("", secDhGetPublicKey(x, pubX, &(int){ SEC_DH_KEY_SIZE }), SEC_ERR_NOT_READY); +1 src/test/serial/testSerial.c|plainInt|for (int b = 0; b < 8; b++) { +1 src/test/serial/testSerial.c|plainInt|for (int i = 0; i < 4; i++) { +1 src/test/serial/testSerial.c|plainInt|for (int i = 0; i < XTEA_ROUNDS; i++) { +1 src/test/serial/testSerial.c|plainInt|int bHandshakeRc; +1 src/test/serial/testSerial.c|plainInt|int len = SEC_DH_KEY_SIZE; +1 src/test/serial/testSerial.c|plainInt|int len; +1 src/test/serial/testSerial.c|plainInt|int n; +1 src/test/serial/testSerial.c|plainInt|printf("", (int)sPassCount, (int)sFailCount); +1 src/test/serial/testSerial.c|plainInt|printf("", file, line, name, (long long)actual, (long long)expected); +1 src/test/serial/testSerial.c|plainInt|static bool linkPairOpen(LinkPairT *pair, int portA, int portB, SinkT *sinkA, SinkT *sinkB) { +1 src/test/serial/testSerial.c|plainInt|static bool linkPairOpen(LinkPairT *pair, int portA, int portB, SinkT *sinkA, SinkT *sinkB); +1 src/test/serial/testSerial.c|plainInt|static int32_t readRaw(int com, uint8_t *buf, int32_t max) { +1 src/test/serial/testSerial.c|plainInt|static int32_t readRaw(int com, uint8_t *buf, int32_t max); +1 src/test/serial/testSerial.c|plainInt|static void checkEq(const char *name, int64_t actual, int64_t expected, const char *file, int line) { +1 src/test/serial/testSerial.c|plainInt|static void checkEq(const char *name, int64_t actual, int64_t expected, const char *file, int line); +1 src/test/serial/testSerial.c|plainInt|static void checkMem(const char *name, const void *actual, const void *expected, int32_t len, const char *file, int line) { +1 src/test/serial/testSerial.c|plainInt|static void checkMem(const char *name, const void *actual, const void *expected, int32_t len, const char *file, int line); +1 src/test/serial/testSerial.c|plainInt|static void checkTrue(const char *name, bool cond, const char *file, int line) { +1 src/test/serial/testSerial.c|plainInt|static void checkTrue(const char *name, bool cond, const char *file, int line); +1 src/test/serial/testSerial.c|plainInt|static void pktPairOpen(PktPairT *pair, int window, SinkT *sinkA, SinkT *sinkB) { +1 src/test/serial/testSerial.c|plainInt|static void pktPairOpen(PktPairT *pair, int window, SinkT *sinkA, SinkT *sinkB); +1 src/test/serial/testSerial.c|plainInt|static void sinkLinkRecv(void *ctx, const uint8_t *data, int len, uint8_t channel) { +1 src/test/serial/testSerial.c|plainInt|static void sinkLinkRecv(void *ctx, const uint8_t *data, int len, uint8_t channel); +1 src/test/serial/testSerial.c|plainInt|static void sinkPktRecv(void *ctx, const uint8_t *data, int len) { +1 src/test/serial/testSerial.c|plainInt|static void sinkPktRecv(void *ctx, const uint8_t *data, int len); +1 src/test/shell/tasksTest.c|plainInt|snprintf(entry, sizeof(entry), "", (unsigned long)id); +1 src/test/shell/tasksTest.c|plainInt|snprintf(name, sizeof(name), "", (long)i + 1); +1 src/test/shell/tasksTest.c|plainInt|snprintf(name, sizeof(name), "", (long)i); +1 src/test/tools/hlpcLibTest.c|plainInt|fprintf(stderr, "", (int)rc); +1 src/test/tools/hlpcLibTest.c|plainInt|fprintf(stderr, "", (int)st.calls, (int)(expected + 1)); +1 src/test/tools/hlpcLibTest.c|plainInt|fprintf(stderr, "", (int)st.lastCurrent, (int)expected); +1 src/test/tools/hlpcLibTest.c|plainInt|fprintf(stderr, "", (int)st.total); +1 src/test/tools/hlpcLibTest.c|plainInt|fprintf(stderr, "", (int)st.total, (int)st.totalStable, (int)expected); +1 src/test/tools/hlpcLibTest.c|plainInt|printf("", (int)expected, (int)st.calls); +1 src/test/tools/hlpcLibTest.c|plainInt|printf("", (int)rc, (int)st.lastCurrent, (int)st.total); +1 src/test/unit/testClock.c|plainInt|ts.tv_nsec = (long)(ms % CLOCK_HOST_HZ) * CLOCK_NS_PER_MS; +1 src/test/unit/testDraw.c|plainInt|unitFail(__FILE__, __LINE__, "", (long)ci, (long)bpp); +1 src/test/unit/testDraw.c|plainInt|unitFail(__FILE__, __LINE__, "", (long)ci, (long)bpp, (long)firstBad); +1 src/test/unit/testDraw.c|plainInt|unitFail(__FILE__, line, "", (long)used); +1 src/test/unit/testUtil.c|plainInt|static int compareNames(const void *a, const void *b) { +1 src/test/unit/testUtil.c|plainInt|static int compareNames(const void *a, const void *b); +1 src/test/unit/unitMain.c|plainInt|fprintf(stderr, "", file, (long)line); +1 src/test/unit/unitMain.c|plainInt|printf("", (long)unitPassCount, (long)unitFailCount); +1 src/test/unit/unitMain.c|plainInt|printf("", sSections[i].name, (long)ran, (long)sSectionFails); +1 src/test/widgets/testDestroy.c|plainInt|snprintf(text, sizeof(text), "", (int)k); +1 src/test/widgets/testInput.c|plainInt|printf("", (int)len, expect); +2 src/test/widgets/testInput.c|plainInt|snprintf(text, sizeof(text), "", (int)i); +1 src/test/widgets/testMisc.c|plainInt|printf("", (int)c->sentLen); +1 src/test/widgets/testMisc.c|plainInt|snprintf(line, sizeof(line), "", (int)i); +1 src/test/widgets/testProps.c|plainInt|printf("", (int)count); +1 src/test/widgets/testProps.c|plainInt|snprintf(maxStr, sizeof(maxStr), "", (int)INT32_MAX); +1 src/test/widgets/testProps.c|plainInt|snprintf(minStr, sizeof(minStr), "", (int)INT32_MIN); +1 src/test/widgets/testRange.c|plainInt|snprintf(text, sizeof(text), "", (int)i); +1 src/test/widgets/widgetsMain.c|plainInt|printf("", (int)tProcessInit(DVXTEST_WIDGET_DIR)); +1 src/test/widgets/wtCommon.c|plainInt|snprintf(out, outSize, "", (int)((int32_t (*)(const WidgetT *))p->getFn)(w)); +1 src/tools/bmp2raw.c|plainInt|printf("", argv[1], argv[2], (int)(sizeof(vgaPal) + PIXEL_COUNT)); +1 src/tools/dvxResWrite.h|plainInt|earliest = (long)entry.offset; +2 src/tools/dvxResWrite.h|plainInt|fseek(f, -(long)sizeof(DvxResFooterT), SEEK_END); +1 src/tools/dvxResWrite.h|plainInt|if ((long)entry.offset < earliest) { +1 src/tools/dvxResWrite.h|plainInt|if (fileSize < (long)sizeof(DvxResFooterT)) { +1 src/tools/dvxResWrite.h|plainInt|if (footer.entryCount > (uint32_t)((fileSize - (long)sizeof(DvxResFooterT)) / (long)sizeof(DvxResDirEntryT))) { +1 src/tools/dvxResWrite.h|plainInt|long dxeSize = dvxResDxeContentSize(path); +1 src/tools/dvxResWrite.h|plainInt|long earliest = footer.dirOffset; +2 src/tools/dvxResWrite.h|plainInt|long fileSize = ftell(f); +1 src/tools/dvxResWrite.h|plainInt|long maxDirOffset = fileSize - (long)sizeof(DvxResFooterT); +1 src/tools/dvxResWrite.h|plainInt|static int32_t dvxResReadExisting(const char *path, long dxeSize, DvxResDirEntryT **outEntries, uint32_t *outCount, uint8_t ***outData) { +1 src/tools/dvxResWrite.h|plainInt|static int32_t dvxResReadExisting(const char *path, long dxeSize, DvxResDirEntryT **outEntries, uint32_t *outCount, uint8_t ***outData); +1 src/tools/dvxResWrite.h|plainInt|static int32_t dvxResWriteBlock(const char *path, long dxeSize, DvxResDirEntryT *entries, uint32_t count, uint8_t **data) { +1 src/tools/dvxResWrite.h|plainInt|static int32_t dvxResWriteBlock(const char *path, long dxeSize, DvxResDirEntryT *entries, uint32_t count, uint8_t **data); +1 src/tools/dvxResWrite.h|plainInt|static long dvxResDxeContentSize(const char *path) { +1 src/tools/dvxResWrite.h|plainInt|static long dvxResDxeContentSize(const char *path); +1 src/tools/dvxhlpc.c|plainInt|fprintf(stderr, "", (int)errorCount); +2 src/tools/dvxhlpc.c|plainInt|fprintf(stderr, "", currentFile ? currentFile : "", (int)currentLine); +1 src/tools/dvxhlpc.c|plainInt|long imgSize = (long)imgLen; +1 src/tools/dvxhlpc.c|plainInt|static int compareIndexEntries(const void *a, const void *b) { +1 src/tools/dvxhlpc.c|plainInt|static int compareIndexEntries(const void *a, const void *b); +1 src/tools/dvxhlpc.c|plainInt|static int compareTrigrams(const void *a, const void *b) { +1 src/tools/dvxhlpc.c|plainInt|static int compareTrigrams(const void *a, const void *b); +1 src/tools/dvxhlpc.c|plainInt|while (fgets(rline, (int)sizeof(rline), rf)) { +2 src/tools/dvxres.c|plainInt|fprintf(stderr, "", manifestPath, (long)lineNum); +1 src/tools/dvxres.c|plainInt|fprintf(stderr, "", manifestPath, (long)lineNum, dataArg); +1 src/tools/dvxres.c|plainInt|fprintf(stderr, "", manifestPath, (long)lineNum, typeStr); +1 src/tools/dvxres.c|plainInt|fseek(f, -(long)sizeof(DvxResFooterT), SEEK_END); +2 src/tools/dvxres.c|plainInt|long dxeSize = dvxResDxeContentSize(dxePath); +1 src/tools/dvxres.c|plainInt|long fileSize = ftell(f); +1 src/tools/mkicon.c|plainInt|int32_t x = 15 + (int)(11.0 * __builtin_cos(rad)); +1 src/tools/mkicon.c|plainInt|int32_t x = 15 + (int)(13.0 * __builtin_cos(rad)); +1 src/tools/mkicon.c|plainInt|int32_t y = 15 + (int)(11.0 * __builtin_sin(rad)); +1 src/tools/mkicon.c|plainInt|int32_t y = 15 + (int)(13.0 * __builtin_sin(rad)); +1 src/tools/mkwgticon.c|plainInt|uint16_t bits = (ch >= '' && ch <= '') ? font[(int)ch] : 0; +1 src/tools/proxy/proxy.c|plainInt|for (int i = 0; i < inLen; i++) { +1 src/tools/proxy/proxy.c|plainInt|for (int i = 0; i < len; i++) { +1 src/tools/proxy/proxy.c|plainInt|int bbsFd = *(int *)ctx; +1 src/tools/proxy/proxy.c|plainInt|int bbsFd = -1; +1 src/tools/proxy/proxy.c|plainInt|int bbsPort = DEFAULT_BBS_PORT; +1 src/tools/proxy/proxy.c|plainInt|int cleanLen = telnetFilter(bbsFd, raw, (int)n, clean); +1 src/tools/proxy/proxy.c|plainInt|int clientFd; +2 src/tools/proxy/proxy.c|plainInt|int fd; +1 src/tools/proxy/proxy.c|plainInt|int listenFd; +1 src/tools/proxy/proxy.c|plainInt|int listenPort = DEFAULT_LISTEN_PORT; +1 src/tools/proxy/proxy.c|plainInt|int opt = 1; +1 src/tools/proxy/proxy.c|plainInt|int outLen = 0; +1 src/tools/proxy/proxy.c|plainInt|int pr = poll(&listenPoll, 1, 500); +1 src/tools/proxy/proxy.c|plainInt|int rc = secLinkSend(link, clean, cleanLen, CHANNEL_TERMINAL, true, false); +1 src/tools/proxy/proxy.c|plainInt|int rc; +1 src/tools/proxy/proxy.c|plainInt|int sent = 0; +1 src/tools/proxy/proxy.c|plainInt|secLinkSend(link, (const uint8_t *)testMsg, (int)strlen(testMsg), CHANNEL_TERMINAL, true, false); +1 src/tools/proxy/proxy.c|plainInt|sent += (int)n; +1 src/tools/proxy/proxy.c|plainInt|static int connectToBbs(const char *host, int port) { +1 src/tools/proxy/proxy.c|plainInt|static int connectToBbs(const char *host, int port); +1 src/tools/proxy/proxy.c|plainInt|static int createListenSocket(int port) { +1 src/tools/proxy/proxy.c|plainInt|static int createListenSocket(int port); +1 src/tools/proxy/proxy.c|plainInt|static int sClientFd = -1; +1 src/tools/proxy/proxy.c|plainInt|static int sTelState = TS_DATA; +1 src/tools/proxy/proxy.c|plainInt|static int telnetFilter(int bbsFd, const uint8_t *in, int inLen, uint8_t *out) { +1 src/tools/proxy/proxy.c|plainInt|static int telnetFilter(int bbsFd, const uint8_t *in, int inLen, uint8_t *out); +1 src/tools/proxy/proxy.c|plainInt|static void onRecvFromDos(void *ctx, const uint8_t *data, int len, uint8_t channel) { +1 src/tools/proxy/proxy.c|plainInt|static void onRecvFromDos(void *ctx, const uint8_t *data, int len, uint8_t channel); +1 src/tools/proxy/proxy.c|plainInt|static void sigHandler(int sig) { +1 src/tools/proxy/proxy.c|plainInt|static void sigHandler(int sig); +1 src/tools/proxy/proxy.c|plainInt|static void telnetRespond(int bbsFd, uint8_t cmd, uint8_t opt) { +1 src/tools/proxy/proxy.c|plainInt|static void telnetRespond(int bbsFd, uint8_t cmd, uint8_t opt); +1 src/tools/proxy/sockShim.c|plainInt|int flags = fcntl(fd, F_GETFL, 0); +1 src/tools/proxy/sockShim.c|plainInt|int rs232Close(int com) { +1 src/tools/proxy/sockShim.c|plainInt|int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake) { +1 src/tools/proxy/sockShim.c|plainInt|int rs232Read(int com, char *data, int len) { +1 src/tools/proxy/sockShim.c|plainInt|int rs232Write(int com, const char *data, int len) { +1 src/tools/proxy/sockShim.c|plainInt|int sent = 0; +1 src/tools/proxy/sockShim.c|plainInt|return (int)n; +1 src/tools/proxy/sockShim.c|plainInt|sent += (int)n; +1 src/tools/proxy/sockShim.c|plainInt|static int sFds[RS232_NUM_PORTS] = { -1, -1, -1, -1 }; +1 src/tools/proxy/sockShim.c|plainInt|void sockShimSetFd(int com, int fd) { +1 src/tools/proxy/sockShim.h|plainInt|int rs232Close(int com); +1 src/tools/proxy/sockShim.h|plainInt|int rs232Open(int com, int32_t bps, int dataBits, char parity, int stopBits, int handshake); +1 src/tools/proxy/sockShim.h|plainInt|int rs232Read(int com, char *data, int len); +1 src/tools/proxy/sockShim.h|plainInt|int rs232Write(int com, const char *data, int len); +1 src/tools/proxy/sockShim.h|plainInt|void sockShimSetFd(int com, int fd); +1 src/tools/proxy/stubs/pc.h|plainInt|static inline unsigned char inportb(unsigned short port) { +1 src/tools/proxy/stubs/pc.h|plainInt|static inline void outportb(unsigned short port, unsigned char val) { +1 src/tools/proxy/stubs/sys/farptr.h|plainInt|static inline unsigned long _farpeekl(unsigned short sel, unsigned long ofs) { +1 src/widgets/kpunch/ansiTerm/widgetAnsiTerm.c|plainInt|int32_t len = snprintf(reply, sizeof(reply), "", (long)(at->cursorRow + 1), (long)(at->cursorCol + 1)); +1 src/widgets/kpunch/ansiTerm/widgetAnsiTerm.c|plainInt|int32_t len = snprintf(reply, sizeof(reply), "", (long)at->rows, (long)at->cols); +1 src/widgets/kpunch/spinner/widgetSpinner.c|plainInt|d->len = snprintf(d->buf, sizeof(d->buf), "", (int)d->decimals, d->realValue); +1 src/widgets/kpunch/spinner/widgetSpinner.c|plainInt|d->len = snprintf(d->buf, sizeof(d->buf), "", (int)d->value); +1 src/widgets/kpunch/textInput/widgetTextInput.c|plainInt|int32_t numLen = snprintf(numBuf, sizeof(numBuf), "", (int)(row + 1)); +1 src/test/unit/testPlatform.c|plainInt|volatile int crashSignal = 0; +1 src/test/unit/testPlatform.c|plainInt|int status = 0; +2 src/test/unit/testPlatform.c|plainInt|int w = 0; +2 src/test/unit/testPlatform.c|plainInt|int h = 0; +2 src/test/unit/testPlatform.c|plainInt|int comp = 0; +1 src/test/tools/resFailTest.c|funcOrder|runDirect diff --git a/src/test/lint/cppcheckBaseline.txt b/src/test/lint/cppcheckBaseline.txt new file mode 100644 index 0000000..9c87cbb --- /dev/null +++ b/src/test/lint/cppcheckBaseline.txt @@ -0,0 +1,59 @@ +# cppcheck baseline -- generated by staticGate.sh --update +# format: +1 src/apps/kpunch/cpanel/cpanel.c|unsignedLessThanZero|Checking if unsigned expression '(sThemeEntries)?((struct stbds_array_header*)(sThemeEntries)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/cpanel/cpanel.c|unsignedLessThanZero|Checking if unsigned expression '(sThemeLabels)?((struct stbds_array_header*)(sThemeLabels)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/cpanel/cpanel.c|unsignedLessThanZero|Checking if unsigned expression '(sVideoLabels)?((struct stbds_array_header*)(sVideoLabels)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/cpanel/cpanel.c|unsignedLessThanZero|Checking if unsigned expression '(sWpaperEntries)?((struct stbds_array_header*)(sWpaperEntries)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/cpanel/cpanel.c|unsignedLessThanZero|Checking if unsigned expression '(sWpaperLabels)?((struct stbds_array_header*)(sWpaperLabels)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/compiler/compact.c|variableScope|The scope of the variable 'lineCount' can be reduced. +1 src/apps/kpunch/dvxbasic/compiler/lexer.c|knownConditionTrueFalse|Condition '!isdigit((unsigned char)c)' is always true +1 src/apps/kpunch/dvxbasic/compiler/obfuscate.c|knownConditionTrueFalse|Condition 'valueEnd-valueStart>=2' is always false +1 src/apps/kpunch/dvxbasic/compiler/obfuscate.c|knownConditionTrueFalse|Condition 'valueLen>0' is always false +1 src/apps/kpunch/dvxbasic/compiler/parser.c|shiftNegativeLHS|Shifting a negative value is technically undefined behaviour +1 src/apps/kpunch/dvxbasic/compiler/parser.c|unusedStructMember|struct member 'BuiltinFuncT::resultType' is never used. +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|constParameterCallback|Parameter 'win' can be declared as pointer to const. However it seems that 'onFormClose' is a callback function, if 'win' is declared with const you might also need to cast function pointer(s). +1 src/apps/kpunch/dvxbasic/formrt/formrt.c|constParameterCallback|Parameter 'win' can be declared as pointer to const. However it seems that 'onFormMenu' is a callback function, if 'win' is declared with const you might also need to cast function pointer(s). +1 src/apps/kpunch/dvxbasic/formrt/frmParser.c|unreadVariable|Variable 'inForm' is assigned a value that is never used. +2 src/apps/kpunch/dvxbasic/ide/ideDesigner.c|constVariable|Variable 'hx' can be declared as const array +2 src/apps/kpunch/dvxbasic/ide/ideDesigner.c|constVariable|Variable 'hy' can be declared as const array +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|knownConditionTrueFalse|Condition 'idx>=0' is always true +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|unsignedLessThanZero|Checking if unsigned expression '(sBreakpoints)?((struct stbds_array_header*)(sBreakpoints)-1)->capacity:0' is less than zero. +2 src/apps/kpunch/dvxbasic/ide/ideMain.c|unsignedLessThanZero|Checking if unsigned expression '(sEvtItems)?((struct stbds_array_header*)(sEvtItems)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|unsignedLessThanZero|Checking if unsigned expression '(sObjItems)?((struct stbds_array_header*)(sObjItems)-1)->capacity:0' is less than zero. +2 src/apps/kpunch/dvxbasic/ide/ideMain.c|unsignedLessThanZero|Checking if unsigned expression '(sProcTable)?((struct stbds_array_header*)(sProcTable)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|unsignedLessThanZero|Checking if unsigned expression '(sRecentFiles)?((struct stbds_array_header*)(sRecentFiles)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|unsignedLessThanZero|Checking if unsigned expression 'rows->cells?((struct stbds_array_header*)(rows->cells)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/ide/ideMain.c|unsignedLessThanZero|Checking if unsigned expression 'rows->strs?((struct stbds_array_header*)(rows->strs)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/ide/ideMenuEditor.c|unsignedLessThanZero|Checking if unsigned expression '(sLabelBufs)?((struct stbds_array_header*)(sLabelBufs)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/ide/ideMenuEditor.c|unsignedLessThanZero|Checking if unsigned expression '(sLabels)?((struct stbds_array_header*)(sLabels)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/ide/ideProperties.c|unsignedLessThanZero|Checking if unsigned expression '(sCellData)?((struct stbds_array_header*)(sCellData)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/ide/ideProperties.c|unsignedLessThanZero|Checking if unsigned expression '(sTreeLabels)?((struct stbds_array_header*)(sTreeLabels)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/ide/ideToolbox.c|unsignedLessThanZero|Checking if unsigned expression '(sTbxTools)?((struct stbds_array_header*)(sTbxTools)-1)->capacity:0' is less than zero. +1 src/apps/kpunch/dvxbasic/runtime/values.c|unusedStructMember|struct member 'Anonymous1::nul' is never used. +1 src/apps/kpunch/dvxbasic/runtime/vm.c|variableScope|The scope of the variable 'inSection' can be reduced. +1 src/apps/kpunch/dvxbasic/stub/basrun.c|variableScope|The scope of the variable 'directive' can be reduced. +1 src/apps/kpunch/dvxbasic/test_suite.c|constVariable|Variable 'frmLens' can be declared as const array +1 src/apps/kpunch/dvxbasic/test_suite.c|variableScope|The scope of the variable 'detail' can be reduced. +1 src/apps/kpunch/dvxhelp/dvxhelp.c|constParameterCallback|Parameter 'w' can be declared as pointer to const. However it seems that 'helpRulePaint' is a callback function, if 'w' is declared with const you might also need to cast function pointer(s). +1 src/libs/kpunch/libdvx/dvxApp.c|shadowVariable|Local variable 'k' shadows outer variable +1 src/libs/kpunch/libdvx/dvxDialog.c|constParameterCallback|Parameter 'w' can be declared as pointer to const. However it seems that 'fdOnPathKey' is a callback function, if 'w' is declared with const you might also need to cast function pointer(s). +1 src/libs/kpunch/listhelp/listHelp.c|unsignedLessThanZero|Checking if unsigned expression '(owned)?((struct stbds_array_header*)(owned)-1)->capacity:0' is less than zero. +1 src/libs/kpunch/taskmgr/shellTaskMgr.c|unsignedLessThanZero|Checking if unsigned expression '(sCells)?((struct stbds_array_header*)(sCells)-1)->capacity:0' is less than zero. +1 src/libs/kpunch/taskmgr/shellTaskMgr.c|unsignedLessThanZero|Checking if unsigned expression '(sRowStrs)?((struct stbds_array_header*)(sRowStrs)-1)->capacity:0' is less than zero. +1 src/libs/kpunch/texthelp/textHelp.c|constParameterCallback|Parameter 'w' can be declared as pointer to const. However it seems that 'textEditClearWidgetRef' is a callback function, if 'w' is declared with const you might also need to cast function pointer(s). +1 src/libs/kpunch/texthelp/textHelp.c|knownConditionTrueFalse|Condition 'drawEndownedItems?((struct stbds_array_header*)(d->ownedItems)-1)->capacity:0' is less than zero. +1 src/widgets/kpunch/dropdown/widgetDropdown.c|unsignedLessThanZero|Checking if unsigned expression 'd->ownedItems?((struct stbds_array_header*)(d->ownedItems)-1)->capacity:0' is less than zero. +1 src/widgets/kpunch/listBox/widgetListBox.c|unsignedLessThanZero|Checking if unsigned expression 'd->ownedItems?((struct stbds_array_header*)(d->ownedItems)-1)->capacity:0' is less than zero. +1 src/widgets/kpunch/listView/widgetListView.c|knownConditionTrueFalse|Condition 'displaySel>=0' is always true +1 src/widgets/kpunch/listView/widgetListView.c|knownConditionTrueFalse|Condition 'from<0' is always false +1 src/widgets/kpunch/listView/widgetListView.c|knownConditionTrueFalse|Condition 'relY<0' is always false +1 src/widgets/kpunch/listView/widgetListView.c|unreadVariable|Variable 'needVSb' is assigned a value that is never used. +1 src/widgets/kpunch/listView/widgetListView.c|unsignedLessThanZero|Checking if unsigned expression 'lv->ownedCells?((struct stbds_array_header*)(lv->ownedCells)-1)->capacity:0' is less than zero. +3 src/widgets/kpunch/textInput/widgetTextInput.c|redundantAssignment|Variable 'ti->selEnd' is reassigned a value before the old one has been used. +1 src/widgets/kpunch/toolbar/widgetToolbar.c|constParameterCallback|Parameter 'w' can be declared as pointer to const. However it seems that 'widgetToolbarPaint' is a callback function, if 'w' is declared with const you might also need to cast function pointer(s). diff --git a/src/test/lint/staticGate.sh b/src/test/lint/staticGate.sh new file mode 100755 index 0000000..40de6df --- /dev/null +++ b/src/test/lint/staticGate.sh @@ -0,0 +1,276 @@ +#!/bin/bash +# +# staticGate.sh -- cppcheck + gcc -fanalyzer gate for the DVX tree. +# +# Runs cppcheck with the same flags/suppressions as ./cppcheck.sh and the +# DJGPP build under -fanalyzer the same way ./analyze.sh does, normalizes +# both outputs to a line-number-free form, and diffs them against the +# checked-in baselines next to this script. New findings (a key that is +# not in the baseline, or whose count grew) fail the gate. +# +# Usage: +# staticGate.sh # run both, gate against baselines +# staticGate.sh --update # regenerate both baselines +# staticGate.sh --cppcheck-only # skip the (slow) analyzer build +# staticGate.sh --analyzer-only +# +# The analyzer pass builds a throwaway copy of the tree in a temp dir so +# the real object files are never touched (and a concurrent normal build +# is not disturbed). +# +# Normalized key formats: +# cppcheck : file|id|message +# analyzer : file|function|message [-Wanalyzer-xxx] +# +# Set STATICGATE_LOGDIR= to keep the raw cppcheck/analyzer output +# (cppcheck.raw, analyzer.raw) and the normalized key lists. +# +# Exit status: 0 = no new findings, 1 = new findings, 2 = tool failure. + +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +CPPCHECK_BASE="$HERE/cppcheckBaseline.txt" +ANALYZER_BASE="$HERE/analyzerBaseline.txt" +REAL_CC="${DJGPP_CC:-$HOME/djgpp/djgpp/bin/i586-pc-msdosdjgpp-gcc}" + +UPDATE=0 +RUN_CPPCHECK=1 +RUN_ANALYZER=1 +for arg in "$@"; do + case "$arg" in + --update) UPDATE=1 ;; + --cppcheck-only) RUN_ANALYZER=0 ;; + --analyzer-only) RUN_CPPCHECK=0 ;; + *) echo "staticGate: unknown option $arg" >&2; exit 2 ;; + esac +done + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +LOGDIR="${STATICGATE_LOGDIR:-}" + +keepLog() { + if [ -n "$LOGDIR" ]; then + mkdir -p "$LOGDIR" + cp "$WORK/$1" "$LOGDIR/$1" + fi +} + +STATUS=0 + +# compareToBaseline NAME CURRENT_KEYS BASELINE_FILE +# CURRENT_KEYS is a file of one normalized key per line (unsorted, with +# duplicates). Baseline lines are " ". Prints new findings +# and returns 1 if any. +compareToBaseline() { + local name="$1" + local current="$2" + local baseline="$3" + local curCounts="$WORK/$name.cur" + local baseCounts="$WORK/$name.base" + + LC_ALL=C sort "$current" | uniq -c | sed -E 's/^ *([0-9]+) /\1 /' > "$curCounts" + + if [ "$UPDATE" -eq 1 ]; then + { + echo "# $name baseline -- generated by staticGate.sh --update" + echo "# format: " + cat "$curCounts" + } > "$baseline" + echo "staticGate: $name baseline written ($(wc -l < "$curCounts") keys) -> $baseline" + return 0 + fi + + if [ -f "$baseline" ]; then + grep -v '^#' "$baseline" > "$baseCounts" + else + : > "$baseCounts" + echo "staticGate: WARNING no $name baseline at $baseline; every finding is new" + fi + + local newCount=0 + local total=0 + while IFS= read -r line; do + local count="${line%% *}" + local key="${line#* }" + total=$((total + count)) + local baseCount + baseCount="$(grep -F -- " $key" "$baseCounts" | awk -v k="$key" '{ n=index($0," "); if (substr($0,n+1)==k) print $1 }' | head -n 1)" + baseCount="${baseCount:-0}" + if [ "$count" -gt "$baseCount" ]; then + echo "NEW [$name] $key (now $count, baseline $baseCount)" + newCount=$((newCount + count - baseCount)) + fi + done < "$curCounts" + + echo "staticGate: $name -- $total finding(s), $newCount new" + if [ "$newCount" -ne 0 ]; then + return 1 + fi + return 0 +} + +# ------------------------------------------------------------ +# cppcheck +# ------------------------------------------------------------ +if [ "$RUN_CPPCHECK" -eq 1 ]; then + if ! command -v cppcheck >/dev/null 2>&1; then + echo "staticGate: cppcheck not installed" >&2 + exit 2 + fi + cd "$ROOT" + INCLUDES=( + -Isrc/libs/kpunch/libdvx + -Isrc/libs/kpunch/libdvx/platform + -Isrc/libs/kpunch/libdvx/thirdparty + -Isrc/libs/kpunch/libtasks + -Isrc/libs/kpunch/texthelp + -Isrc/libs/kpunch/listhelp + -Isrc/libs/kpunch/dvxshell + -Isrc/libs/kpunch/taskmgr + -Isrc/libs/kpunch/serial + -Isrc/widgets/kpunch + -Isrc/apps/kpunch/dvxbasic + -Isrc/apps/kpunch/dvxbasic/compiler + -Isrc/apps/kpunch/dvxbasic/runtime + -Isrc/apps/kpunch/dvxbasic/formrt + ) + DEFINES=( + -DDXE_EXPORT= + -DDVX_WIDGET_IMPL + -D__DJGPP__=2 + ) + SUPPRESS=( + --suppress=missingIncludeSystem + --suppress=unusedFunction + --suppress=constParameterPointer + --suppress=constVariablePointer + --suppress=normalCheckLevelMaxBranches + --suppress=nullPointerRedundantCheck + --suppress=nullPointerArithmeticRedundantCheck + --suppress='*:src/libs/kpunch/libdvx/thirdparty/*' + --suppress='*:src/libs/kpunch/sql/thirdparty/*' + ) + echo "staticGate: running cppcheck..." + cppcheck \ + --enable=warning,style,performance,portability \ + --inline-suppr \ + --std=c99 \ + --platform=unix32 \ + --quiet \ + --template='{file}|{id}|{message}' \ + -j"$(nproc)" \ + -i src/libs/kpunch/sql/thirdparty \ + -i src/libs/kpunch/libdvx/thirdparty \ + -i src/test \ + "${INCLUDES[@]}" \ + "${DEFINES[@]}" \ + "${SUPPRESS[@]}" \ + src 2> "$WORK/cppcheck.raw" > /dev/null + # Keep only finding lines (file|id|message); drop progress chatter. + grep -E '^[^|]+\|[^|]+\|' "$WORK/cppcheck.raw" > "$WORK/cppcheck.keys" || true + keepLog cppcheck.raw + keepLog cppcheck.keys + compareToBaseline cppcheck "$WORK/cppcheck.keys" "$CPPCHECK_BASE" || STATUS=1 +fi + +# ------------------------------------------------------------ +# gcc -fanalyzer (DJGPP build in a scratch copy) +# ------------------------------------------------------------ +if [ "$RUN_ANALYZER" -eq 1 ]; then + if [ ! -x "$REAL_CC" ]; then + echo "staticGate: DJGPP compiler $REAL_CC not executable (set DJGPP_CC)" >&2 + exit 2 + fi + COPY="$WORK/tree" + mkdir -p "$COPY" + echo "staticGate: copying tree to $COPY..." + # rsync keeps the copy small: no git, no build products, no ISO junk. + rsync -a --exclude .git --exclude bin --exclude obj --exclude releases --exclude '*.o' "$ROOT/" "$COPY/" + mkdir -p "$COPY/bin" + + WRAP="$WORK/analyze-cc" + cat > "$WRAP" < "$HOSTWRAP" < "$WORK/analyzer.raw" 2>&1) + compiles="$(grep -c "^$WRAP .* -c \|^$HOSTWRAP " "$WORK/analyzer.raw" || true)" + makeErrors="$(grep -c '^make.*\*\*\*' "$WORK/analyzer.raw" || true)" + echo "staticGate: analyzer compiled $compiles file(s)" + if [ "${compiles:-0}" -eq 0 ]; then + echo "staticGate: analyzer build produced no compiles; see analyzer.raw (STATICGATE_LOGDIR)" >&2 + keepLog analyzer.raw + exit 2 + fi + if [ "${makeErrors:-0}" -ne 0 ]; then + echo "staticGate: WARNING analyzer build had $makeErrors make error(s); coverage may be incomplete" + grep '^make.*\*\*\*' "$WORK/analyzer.raw" + fi + + # Normalize gcc output: "file:line:col: warning: msg [-Wanalyzer-x]" + # preceded by "file: In function 'name':" context lines. Sub-makes + # compile with paths relative to their own directory, so track make's + # "Entering directory" lines to rebuild a repo-relative path. Emit + # file|function|msg [-Wanalyzer-x]. + awk -v root="$COPY/" ' + /^make(\[[0-9]+\])?: Entering directory/ { + dir = $0 + sub(/^.*Entering directory \x27/, "", dir) + sub(/\x27.*$/, "", dir) + gsub(root, "", dir) + next + } + /: In function / { + fn = $0 + sub(/^.*In function \x27/, "", fn) + sub(/\x27:.*$/, "", fn) + next + } + /warning: .*\[-Wanalyzer-[a-z-]+\]/ { + line = $0 + sub(/:[0-9]+:[0-9]+: warning: /, "|", line) + file = line + sub(/\|.*$/, "", file) + msg = line + sub(/^[^|]*\|/, "", msg) + gsub(root, "", file) + if (file !~ /^src\// && dir != "") { + file = dir "/" file + } + # Collapse "a/b/../c" so the key is stable. + while (file ~ /[^\/]+\/\.\.\//) { + sub(/[^\/]+\/\.\.\//, "", file) + } + gsub(/\x27[^\x27]*\x27/, "", msg) + print file "|" fn "|" msg + } + ' "$WORK/analyzer.raw" > "$WORK/analyzer.keys" + keepLog analyzer.raw + keepLog analyzer.keys + compareToBaseline analyzer "$WORK/analyzer.keys" "$ANALYZER_BASE" || STATUS=1 +fi + +if [ "$STATUS" -ne 0 ]; then + echo "staticGate: FAIL" +else + echo "staticGate: PASS" +fi +exit "$STATUS" diff --git a/src/test/lint/styleLint.py b/src/test/lint/styleLint.py new file mode 100755 index 0000000..96b272f --- /dev/null +++ b/src/test/lint/styleLint.py @@ -0,0 +1,675 @@ +#!/usr/bin/env python3 +# styleLint.py -- mechanical CLAUDE.md style gate for the DVX source tree. +# +# Usage: +# styleLint.py [--root DIR] [--baseline FILE] [--update-baseline] +# [--no-baseline] [--summary] [--rules r1,r2] [PATH ...] +# +# Checks C sources under src/ (excluding thirdparty/ and sqlite) for the +# mechanical rules from CLAUDE.md and reports file:line:rule: message. +# A checked-in baseline (baseline.txt next to this script) records the +# existing debt so the gate only fails on NEW violations. Baseline keys +# are file|rule|normalized-line so ordinary line-number drift does not +# produce false "new" hits; only a changed count for a key does. +# +# Exit status: 0 = no new violations, 1 = new violations, 2 = usage error. +# +# Python 3 stdlib only, ASCII only. + +import argparse +import os +import re +import sys + +# ------------------------------------------------------------ +# Rule ids +# ------------------------------------------------------------ + +RULE_ASCII = "ascii" # non-ASCII byte in a text file +RULE_BLOCK_CMT = "blockComment" # /* */ comment (use //) +RULE_BRACES = "braces" # if/else/for/while/do without { } +RULE_MULTI_DECL = "multiDecl" # int32_t a, b; +RULE_MULTI_STMT = "multiStmt" # two statements on one line +RULE_WRAPPED = "wrappedProto" # prototype / definition spans lines +RULE_PROTO_ORDER = "protoOrder" # prototype block not alphabetical +RULE_FUNC_ORDER = "funcOrder" # function definitions not alphabetical +RULE_LAST_FUNC = "lastFunc" # main/appMain/wgtRegister not last +RULE_BLANK_LINES = "blankLines" # fewer than two blank lines between functions +RULE_SNAKE_CASE = "snakeCase" # snake_case function name +RULE_PLAIN_INT = "plainInt" # int/long/short where stdint applies (info) + +ALL_RULES = [ + RULE_ASCII, RULE_BLOCK_CMT, RULE_BRACES, RULE_MULTI_DECL, RULE_MULTI_STMT, + RULE_WRAPPED, RULE_PROTO_ORDER, RULE_FUNC_ORDER, RULE_LAST_FUNC, + RULE_BLANK_LINES, RULE_SNAKE_CASE, RULE_PLAIN_INT, +] + +# Informational rules are reported and counted but never fail the gate. +INFO_RULES = set([RULE_PLAIN_INT]) + +# Functions that must be the last definition in their file. +LAST_FUNCS = set(["main", "appMain", "wgtRegister"]) + +REQUIRED_BLANK_LINES = 2 + +C_EXTENSIONS = (".c", ".h") +TEXT_EXTENSIONS = (".c", ".h", ".dhs", ".bhs", ".md", ".bas", ".frm", ".dbp", ".py", ".sh", ".txt", ".hcf", ".mk") +TEXT_BASENAMES = ("Makefile",) +EXCLUDE_DIRS = ("thirdparty", "sqlite") + +C_KEYWORDS = set([ + "if", "else", "for", "while", "do", "switch", "case", "default", "return", + "goto", "break", "continue", "sizeof", "typedef", "struct", "union", "enum", +]) + +DECL_QUALIFIERS = set([ + "static", "const", "volatile", "register", "extern", "unsigned", "signed", + "struct", "enum", "union", "inline", "__attribute__", +]) + +STMT_KEYWORDS = set(["return", "goto", "break", "continue", "case", "default", "else"]) + +IDENT_RE = r"[A-Za-z_][A-Za-z0-9_]*" +FUNC_HEAD_RE = re.compile(r"^(?P
    [A-Za-z_][A-Za-z0-9_ \t\*]*?)\b(?P" + IDENT_RE + r")\s*\((?P.*)\)\s*(?P.*)$")
    +CTRL_RE        = re.compile(r"^\s*(?:\}\s*)?(?Pif|for|while|switch)\s*\(")
    +ELSE_RE        = re.compile(r"^\s*(?:\}\s*)?else\b(?P.*)$")
    +DO_RE          = re.compile(r"^\s*(?:\}\s*)?do\b(?P.*)$")
    +PLAIN_INT_RE   = re.compile(r"(? 127]
    +        if bad:
    +            # Key text is escaped so the baseline itself stays ASCII.
    +            escaped = "".join(chr(b) if b < 128 else "\\x%02x" % b for b in raw)
    +            checker.add(path, lineNo, RULE_ASCII, "non-ASCII byte 0x%02x" % bad[0], escaped)
    +
    +
    +def checkCFile(checker, path, text):
    +    src   = stripSource(text)
    +    lines = src.lines
    +    code  = src.code
    +
    +    for lineNo in src.blockStarts:
    +        checker.add(path, lineNo, RULE_BLOCK_CMT, "block comment; use //", lines[lineNo - 1])
    +
    +    braceDepth   = 0
    +    parenDepth   = 0
    +    protoBlock   = []      # (name, lineNo) of consecutive prototypes
    +    funcDefs     = []      # (name, lineNo)
    +    lastFuncEnd  = 0       # line number of the '}' that closed the last function
    +    inFunction   = False
    +    n            = len(code)
    +
    +    def flushProtoBlock():
    +        block = [(nm, ln) for (nm, ln) in protoBlock if nm not in LAST_FUNCS]
    +        if len(block) < 2:
    +            return
    +        for idx in range(1, len(block)):
    +            prevName, prevLine = block[idx - 1]
    +            name, lineNo       = block[idx]
    +            if sortKey(name) < sortKey(prevName):
    +                checker.add(path, lineNo, RULE_PROTO_ORDER, "prototype %s out of order (after %s)" % (name, prevName), name)
    +
    +    for i in range(n):
    +        lineNo   = i + 1
    +        cl       = code[i]
    +        stripped = cl.strip()
    +
    +        if src.inComment[i]:
    +            continue
    +
    +        if isPreprocessor(cl) or (i > 0 and code[i - 1].rstrip().endswith("\\")):
    +            continue
    +
    +        atFileScope = (braceDepth == 0)
    +
    +        # Blank line or non-prototype line ends a prototype block.
    +        if atFileScope and protoBlock:
    +            head = matchFunctionHead(cl)
    +            if not (head and head[1]):
    +                flushProtoBlock()
    +                protoBlock = []
    +
    +        if atFileScope and parenDepth == 0 and stripped:
    +            head = matchFunctionHead(cl)
    +            if head:
    +                name, isProto, isDef = head
    +                if isProto:
    +                    protoBlock.append((name, lineNo))
    +                elif isDef:
    +                    # Blank lines between the previous function and this one
    +                    # (comments in between are allowed; blanks are counted
    +                    # between the closing brace and the first non-blank line).
    +                    if lastFuncEnd:
    +                        blanks = 0
    +                        j      = lastFuncEnd
    +                        while j < i and code[j].strip() == "" and lines[j].strip() == "":
    +                            blanks += 1
    +                            j      += 1
    +                        if blanks < REQUIRED_BLANK_LINES:
    +                            checker.add(path, lineNo, RULE_BLANK_LINES, "%d blank line(s) before %s; need %d" % (blanks, name, REQUIRED_BLANK_LINES), name)
    +                    if SNAKE_RE.match(name) and not name.isupper():
    +                        checker.add(path, lineNo, RULE_SNAKE_CASE, "snake_case function name %s" % name, name)
    +                    funcDefs.append((name, lineNo))
    +                    inFunction = True
    +
    +        # Wrapped prototypes/definitions: file-scope line that opens a
    +        # paren it does not close.
    +        if atFileScope and stripped:
    +            pd = parenDelta(cl)
    +            if parenDepth == 0 and pd > 0 and matchWrappedHead(cl):
    +                checker.add(path, lineNo, RULE_WRAPPED, "function head wraps across lines", cl)
    +            parenDepth += pd
    +            if parenDepth < 0:
    +                parenDepth = 0
    +
    +        # Statement-level rules inside function bodies.
    +        if braceDepth > 0 and stripped:
    +            checkStatementLine(checker, path, lineNo, cl, code, i)
    +
    +        # plainInt is informational and applies everywhere.
    +        if stripped and not isPreprocessor(cl):
    +            checkPlainInt(checker, path, lineNo, cl)
    +
    +        # Multiple declarations can also appear at file scope.
    +        if atFileScope and stripped:
    +            checkMultiDecl(checker, path, lineNo, cl)
    +
    +        bd          = braceDelta(cl)
    +        braceDepth += bd
    +        if braceDepth < 0:
    +            braceDepth = 0
    +        if inFunction and braceDepth == 0 and bd < 0:
    +            inFunction  = False
    +            lastFuncEnd = lineNo
    +
    +    flushProtoBlock()
    +
    +    # Function ordering.
    +    ordered  = [(name, ln) for (name, ln) in funcDefs if name not in LAST_FUNCS]
    +    for idx in range(1, len(ordered)):
    +        prevName, prevLine = ordered[idx - 1]
    +        name, lineNo       = ordered[idx]
    +        if sortKey(name) < sortKey(prevName):
    +            checker.add(path, lineNo, RULE_FUNC_ORDER, "function %s out of order (after %s)" % (name, prevName), name)
    +    for idx, (name, lineNo) in enumerate(funcDefs):
    +        if name in LAST_FUNCS and idx != len(funcDefs) - 1:
    +            checker.add(path, lineNo, RULE_LAST_FUNC, "%s must be the last function in the file" % name, name)
    +
    +
    +def matchWrappedHead(codeLine):
    +    # A file-scope line with an identifier immediately followed by '(' and
    +    # no closing paren for it: candidate wrapped prototype/definition.
    +    s = codeLine.strip()
    +    if s.startswith("#") or s.startswith("{") or s.startswith("}"):
    +        return False
    +    m = re.search(r"\b(" + IDENT_RE + r")\s*\(", s)
    +    if not m:
    +        return False
    +    name = m.group(1)
    +    if name in C_KEYWORDS:
    +        return False
    +    if name.isupper():
    +        # Macro invocations (DXE_EXPORT tables, stb-style helpers) may
    +        # legitimately span lines; not a function head.
    +        return False
    +    return True
    +
    +
    +def checkStatementLine(checker, path, lineNo, cl, code, i):
    +    s = cl.strip()
    +
    +    # Multiple statements on one line.  Anonymous struct/union/enum
    +    # declarations carry member semicolons inside braces; skip them.
    +    if not re.match(r"^\}?\s*for\b", s) and firstWord(s) not in ("struct", "union", "enum", "typedef"):
    +        if topLevelSemicolons(cl) >= 2:
    +            checker.add(path, lineNo, RULE_MULTI_STMT, "multiple statements on one line", cl)
    +
    +    # Braces after control statements.
    +    m = CTRL_RE.match(cl)
    +    if m and m.group("kw") != "switch":
    +        openIdx  = cl.find("(", m.start("kw"))
    +        closeIdx = closingParenIndex(cl, openIdx)
    +        if closeIdx >= 0:
    +            rest = cl[closeIdx + 1:].strip()
    +            if rest == "":
    +                if nextNonBlankStartsWith(code, i, "{") is False:
    +                    checker.add(path, lineNo, RULE_BRACES, "%s body without braces" % m.group("kw"), cl)
    +            elif rest == ";":
    +                # do { } while (x);  -- fine.  A bare 'while (x);' or
    +                # 'for (...);' empty loop is flagged unless preceded by '}'.
    +                if m.group("kw") == "while" and (s.startswith("}") or prevNonBlankEndsWith(code, i, "}")):
    +                    pass
    +                else:
    +                    checker.add(path, lineNo, RULE_BRACES, "%s with empty body; use braces" % m.group("kw"), cl)
    +            elif not rest.startswith("{"):
    +                checker.add(path, lineNo, RULE_BRACES, "%s body without braces" % m.group("kw"), cl)
    +
    +    m = ELSE_RE.match(cl)
    +    if m:
    +        rest = m.group("rest").strip()
    +        if rest == "":
    +            if nextNonBlankStartsWith(code, i, "{") is False:
    +                checker.add(path, lineNo, RULE_BRACES, "else body without braces", cl)
    +        elif not (rest.startswith("{") or re.match(r"^if\b", rest)):
    +            checker.add(path, lineNo, RULE_BRACES, "else body without braces", cl)
    +
    +    m = DO_RE.match(cl)
    +    if m:
    +        rest = m.group("rest").strip()
    +        if rest == "":
    +            if nextNonBlankStartsWith(code, i, "{") is False:
    +                checker.add(path, lineNo, RULE_BRACES, "do body without braces", cl)
    +        elif not rest.startswith("{"):
    +            checker.add(path, lineNo, RULE_BRACES, "do body without braces", cl)
    +
    +    checkMultiDecl(checker, path, lineNo, cl)
    +
    +
    +def checkMultiDecl(checker, path, lineNo, cl):
    +    s = cl.strip()
    +    if not s.endswith(";"):
    +        return
    +    if firstWord(s) in STMT_KEYWORDS or firstWord(s) in ("typedef",):
    +        return
    +    if not DECL_START_RE.match(s):
    +        return
    +    # Exclude function prototypes / calls where the commas are inside parens.
    +    head = matchFunctionHead(cl)
    +    if head:
    +        return
    +    if topLevelCommas(s) == 0:
    +        return
    +    # A top-level comma in a declaration line: int32_t a, b;  or  a = 1, b = 2;
    +    checker.add(path, lineNo, RULE_MULTI_DECL, "multiple declarations in one statement", cl)
    +
    +
    +def checkPlainInt(checker, path, lineNo, cl):
    +    s = cl.strip()
    +    if s.startswith("#"):
    +        return
    +    m = PLAIN_INT_RE.search(cl)
    +    if not m:
    +        return
    +    # main() and its argc are conventional; printf-family and libc
    +    # signatures that genuinely take int are still flagged as info.
    +    if re.search(r"\bint\s+main\s*\(", cl) or re.search(r"\bint\s+argc\b", cl):
    +        return
    +    checker.add(path, lineNo, RULE_PLAIN_INT, "plain '%s'; consider stdint type" % m.group(0), cl)
    +
    +
    +def nextNonBlankStartsWith(code, i, prefix):
    +    j = i + 1
    +    while j < len(code):
    +        s = code[j].strip()
    +        if s:
    +            return s.startswith(prefix)
    +        j += 1
    +    return None
    +
    +
    +def prevNonBlankEndsWith(code, i, suffix):
    +    j = i - 1
    +    while j >= 0:
    +        s = code[j].strip()
    +        if s:
    +            return s.endswith(suffix)
    +        j -= 1
    +    return False
    +
    +
    +# ------------------------------------------------------------
    +# File discovery
    +# ------------------------------------------------------------
    +
    +def isExcluded(relPath):
    +    parts = relPath.split(os.sep)
    +    for p in parts:
    +        if p in EXCLUDE_DIRS:
    +            return True
    +    return False
    +
    +
    +def isTextFile(name):
    +    if name in TEXT_BASENAMES:
    +        return True
    +    if name.lower().endswith("baseline.txt"):
    +        return False
    +    return name.endswith(TEXT_EXTENSIONS)
    +
    +
    +def collectFiles(root, targets):
    +    files = []
    +    for target in targets:
    +        full = os.path.join(root, target) if not os.path.isabs(target) else target
    +        if os.path.isfile(full):
    +            files.append(os.path.relpath(full, root))
    +            continue
    +        for dirPath, dirNames, fileNames in os.walk(full):
    +            dirNames[:] = sorted(d for d in dirNames if d not in EXCLUDE_DIRS)
    +            for fn in sorted(fileNames):
    +                rel = os.path.relpath(os.path.join(dirPath, fn), root)
    +                if isTextFile(fn) and not isExcluded(rel):
    +                    files.append(rel)
    +    return sorted(set(files))
    +
    +
    +# ------------------------------------------------------------
    +# Baseline
    +# ------------------------------------------------------------
    +
    +def loadBaseline(path):
    +    counts = {}
    +    if not os.path.isfile(path):
    +        return counts
    +    with open(path, "r") as f:
    +        for line in f:
    +            line = line.rstrip("\n")
    +            if not line or line.startswith("#"):
    +                continue
    +            countText, sep, key = line.partition(" ")
    +            if not sep:
    +                continue
    +            counts[key] = int(countText)
    +    return counts
    +
    +
    +def saveBaseline(path, violations):
    +    counts = countKeys(violations)
    +    with open(path, "w") as f:
    +        f.write("# styleLint baseline -- generated by styleLint.py --update-baseline\n")
    +        f.write("# format:  ||\n")
    +        for key in sorted(counts):
    +            f.write("%d %s\n" % (counts[key], key))
    +
    +
    +def countKeys(violations):
    +    counts = {}
    +    for v in violations:
    +        counts[v.key] = counts.get(v.key, 0) + 1
    +    return counts
    +
    +
    +# ------------------------------------------------------------
    +# main
    +# ------------------------------------------------------------
    +
    +def main():
    +    scriptDir = os.path.dirname(os.path.abspath(__file__))
    +    parser    = argparse.ArgumentParser(description="DVX mechanical style lint")
    +    parser.add_argument("--root", default=os.path.abspath(os.path.join(scriptDir, "..", "..", "..")), help="repository root (default: three levels up from this script)")
    +    parser.add_argument("--baseline", default=os.path.join(scriptDir, "baseline.txt"), help="baseline file")
    +    parser.add_argument("--update-baseline", action="store_true", help="regenerate the baseline from the current tree")
    +    parser.add_argument("--no-baseline", action="store_true", help="report every violation, ignore the baseline")
    +    parser.add_argument("--summary", action="store_true", help="print only per-rule counts")
    +    parser.add_argument("--rules", default="", help="comma-separated subset of rules to run")
    +    parser.add_argument("paths", nargs="*", default=["src"], help="files or directories under root (default: src)")
    +    args = parser.parse_args()
    +
    +    rules = set(ALL_RULES)
    +    if args.rules:
    +        rules = set(r.strip() for r in args.rules.split(","))
    +        unknown = rules - set(ALL_RULES)
    +        if unknown:
    +            sys.stderr.write("styleLint: unknown rule(s): %s\n" % ", ".join(sorted(unknown)))
    +            return 2
    +
    +    checker = CheckerT(rules)
    +    files   = collectFiles(args.root, args.paths)
    +    for rel in files:
    +        full = os.path.join(args.root, rel)
    +        with open(full, "rb") as f:
    +            data = f.read()
    +        checkAscii(checker, rel, data)
    +        if rel.endswith(C_EXTENSIONS):
    +            checkCFile(checker, rel, data.decode("latin-1"))
    +
    +    violations = checker.violations
    +
    +    if args.update_baseline:
    +        saveBaseline(args.baseline, violations)
    +        sys.stdout.write("styleLint: baseline written to %s (%d entries)\n" % (args.baseline, len(countKeys(violations))))
    +
    +    baseline = {} if (args.no_baseline or args.update_baseline) else loadBaseline(args.baseline)
    +
    +    # Determine which violations are new: for each key, anything beyond the
    +    # baseline count is new (first N occurrences are considered baselined).
    +    seen        = {}
    +    newOnes     = []
    +    for v in sorted(violations, key=lambda x: (x.path, x.lineNo, x.rule)):
    +        seen[v.key] = seen.get(v.key, 0) + 1
    +        if seen[v.key] > baseline.get(v.key, 0):
    +            newOnes.append(v)
    +
    +    toReport = newOnes if not args.update_baseline else violations
    +    if not args.summary:
    +        for v in sorted(toReport, key=lambda x: (x.path, x.lineNo, x.rule)):
    +            sys.stdout.write("%s:%d:%s: %s\n" % (v.path, v.lineNo, v.rule, v.message))
    +
    +    # Per-rule summary (total and new).
    +    totals = {}
    +    news   = {}
    +    for v in violations:
    +        totals[v.rule] = totals.get(v.rule, 0) + 1
    +    for v in newOnes:
    +        news[v.rule] = news.get(v.rule, 0) + 1
    +    sys.stdout.write("styleLint: %d files checked\n" % len(files))
    +    sys.stdout.write("%-14s %8s %8s\n" % ("rule", "total", "new"))
    +    for r in ALL_RULES:
    +        if r in rules:
    +            flag = " (info)" if r in INFO_RULES else ""
    +            sys.stdout.write("%-14s %8d %8d%s\n" % (r, totals.get(r, 0), news.get(r, 0), flag))
    +
    +    gating = [v for v in newOnes if v.rule not in INFO_RULES]
    +    if args.update_baseline:
    +        return 0
    +    if gating:
    +        sys.stdout.write("styleLint: FAIL -- %d new violation(s) not in baseline\n" % len(gating))
    +        return 1
    +    sys.stdout.write("styleLint: PASS\n")
    +    return 0
    +
    +
    +if __name__ == "__main__":
    +    sys.exit(main())
    diff --git a/src/test/samples/golden/basdemo-debug.app b/src/test/samples/golden/basdemo-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..cdea9190efb2d85a42a04c8a2faa3ce1ed1301da
    GIT binary patch
    literal 22198
    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
    T6aT-5FRWZb!CtlU;rf37=`?l9
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/helpedit-debug.app b/src/test/samples/golden/helpedit-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..6e9a4808ba3fd66f4e1c5224fd179ea81f5b4896
    GIT binary patch
    literal 11101
    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
    E1K?#l#Q*>R
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/helpedit-release.app b/src/test/samples/golden/helpedit-release.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..99d7b0028481198601103a9fa65635f0cd2b1a5f
    GIT binary patch
    literal 9946
    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
    ab4r^l$)S3pSJ^OJSRwpdSMuAKdHoBNKZIKV
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/iconed-debug.app b/src/test/samples/golden/iconed-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..fe67568e2e4709e80ed728c7f72ea5a6d3793405
    GIT binary patch
    literal 14931
    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
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/iconed-release.app b/src/test/samples/golden/iconed-release.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..f1716d4cca12404135b3164e21a2d5a479f18068
    GIT binary patch
    literal 12512
    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
    IkRXo#1%53`8UO$Q
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/imgview-debug.app b/src/test/samples/golden/imgview-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..6f30b66cafc691270378e8e8dc83bdcd1701a6cb
    GIT binary patch
    literal 7663
    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
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-comm-debug.app b/src/test/samples/golden/inc-comm-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..71474b25e0220a644f982f95e884f5a63312d761
    GIT binary patch
    literal 6656
    zcmds6e{2)i9e*k5NJCJl#74J?a&&_+O49`x{i8u8iJg?ShKs$}VUVWAzF@=o&b>Ql
    zVWTuCF~S&2sajUlzgjU(ndm01TenVYH&o08Pot;wS=)n&bS>{YG)UV>nf+e
    z8w%UN+YBOmYJdC)V?*I&(xaFWBc8N$gN;F*Sbu|>5EGz
    z%iD_tp3YmX{h4<&-loCXx8Wo=!rJrGY`d0BSuFRxwPc=q?K5{kW;4qb}qiDX<=
    zqEbSSrc^~5)>B$kiffV;i<=VSSL2be71y;k<~0l?s)Z8@Thq4QWHCie4#SqMG52tW
    zZ>OjKQ7;o0$xuu^WkeK7(=7qp#(Kism!(Tl#Z-)VSdD*0aa^}CkGHG8mm%++DNE9a
    zrG%2uje}CeP!NH(4$vNJYvpkU43~Xfk>q09-jyCo6Aqr9t<;GjE80Fmf
    za%2%Z2h~_3F>H(LFAv%W9C+^-gwXVkWlvH9a_l_De*2Mm;MR$6Vn>8RS?`z}v^PSK
    zDP+=7C}%c*+P+69rmwV4oPk=wJ3)Xs?}T5@=6Fd~z;mB{`9o1gigcx%^-drW!U)Rt
    z1&9dhyil>v(K!7$gjDWxw;{eG(>$T{X2DYzJK
    zj
    zM(vMnlS6jgkxY|hJ)D7`d-A0pwnAy9aS?7s@1H4nX+yBF2bra1E?}QvX@>0kg~v=`
    z|B~3#h(fkk3M@jJhW_!z9nIFViM-Wvytknj*T#ddyT`p#
    zkbM+>u@)%p0P66o%Y2={QNW%N{VcD-whX_B!B)DPx2FG0`wV-DZwcA{8uY)EeTATQ
    z?Z-+uiZM;hVdpx6t1}NlJC4DSSc9^?2Lo*Kobo|?e)nTTgVR7AFD%KVRbYketprej
    z!f{lWSW_2L((GBJ;@qY?nx49A{L({~bgZ$x^LS(H)CH@lqxrDiu{32j{AH$rA=lYf
    z80LI`y7=CH#-}|dA&J=cfg;;)U*e(h9bx|h1BAx+qx}X3BD7bb&Ukb#0L`}p@VrpF
    zcpmHi9hc4B$o65#23))+9K~-#@e&j-{x;{En%#xiT-3+s!}17A+HbP`00y%mbonay
    ztiYLl40A9kZql8}Bmi%wQD*dnzP-#Ck6bo1Y$yubh}HQt=q=D1++?;u?bi^K)zIFs
    zNw;yo<;N}Ev=pPqNU-(LaHYXSK(Y8kwL7e;d&7|f><~bJ#f^grJk%{iKggbe(ND=M
    zObOOI3N($3*ku@8g(n+v%_`xkt~fd}$~|@IqfF&M!ikSRYvIU!1hO~bpNEiC!xlZG
    zd>eAh8Z7G*dGA-t_xer@lib-?Nn7t*kmJ>NzMmMS(>Z`PbxQRQ6o3iqcG*(
    z5pdu@JQ*_C&!PMiu<^%+!1gPqUkOK95eNg&{SF0;fDV>{dX&AXedSrLbRz~pe=-`j
    z6t>zegNfG%wO6rdl{5nE>d5>lEf7y6@p5q(5x@rz!l#&Kc%MS$T#cF83pxyX6~xW)
    zqM)jJggxU1g_+bMVN;1DlI#MQP-H`~-p-KuUXY#7)N0y}#Zz7x-VWst!Htqu_t3la`;m|w@3PhG{rdLNQIA?pPVf`&ma
    zBiUKd-N?-kdJ=R7^dV>%`XivDpi`iiK+~X4pcDbn??HDWwvT~63G#zNpgkZBWP(OP
    z&w-{u-vYf1!h2|fb@{fmz(3j+&^nOhPy@RQJv2uy2I6<@*0=mEYa;er2A}_5;;&ox
    zGyMJcxs>Bn#9uDDIY$);75KocP`Dv43vCzT-?WQ`;DD-%e-kdMg4e*mAs5#IuZ>@)
    ziyGk9#lNnL+Tiuz*Kl!N@H+9Ux~K=de*6j-b%Ngze#u4s;5Uj7eVI3O>TK8AUscUT
    zS>o5IPYsiD^HTp>`c>nM}jUK9Pc1LeK0Vda8*idsDr(jH0YSvL>+!e
    zoNcU
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-comm-release.app b/src/test/samples/golden/inc-comm-release.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..01adf9901d490b1e794b28fb2c45bc525494a36b
    GIT binary patch
    literal 6574
    zcmds6ZEPGz86Gz-aUBPzQXmDCW?M)I0Sr#7@S~0z+qZT@d(HX|-z6ljd%0U@FWK9@
    z?(P`}($tZCQ7)I2T(yCSU#)_WQ3?`;LJ=)o<={h=AmTHk6%BtX)=CgXEguPm@Vqm-
    zXXkvmT!O+6Mt*N+-jC<~n3>(3b!fZXImH;ejCL2U3aAORW*K8k{Bb9))J`uI*EN2B
    zClt1TcQ1(Ssr}(Sj14EU*^p`{t#sBgEjA8yVnwx_lQO?xuxH?a7~w?P5Qy`?Gv}92
    zEo{#cc)sMc4;J1m$Sp&2ufvHu$~sFkY^#yYIn4d;Ix=@(`Y2tXHgTbaTNMXa_VBHAa=QM@TYO`Mhr7lvl03Ku#{Lo*{&b6*np51Ufb%w*HL
    znvybRDyOT`h?z4|QreIlEp1DPUr#3!PTDj&m~2@{)JSAhwzgw~%`{cdj=+|!wRdqw
    zcw1=jA(@GbWU029vy!T0n2vz$V0{Vh%Q2;tYO7W{p{Kv7`mQ@zNbVWzXUKb7&XLR!
    zDWhggYrmAVR77CxK}f39(;eN~D_J?iNoUj!xF+wt+aB)Q+E2Yu_vgCVR2};f)5^@7
    z_ic-W*Xk+OqwYb0FM$>JmisodnBvV77ut#sHV=6J_!kEP>rP@FCxnSob1b60
    z0;9cK6z{iOLxMEsy(+ZJ`2)Tq#pPY4$OF-4VWW6o6IumwbkD#5q9FCSJJE*4sJIg=
    zk%e{!s#-HKJcsMAjCuQfczGN`Xv*Wo5vAzzlB|N~0q@egqKp*jYNaSoA`!xfDc(7V
    z2H)wNzH1;=pS9$)#|L6EIDmQ`#H`qxoh{dtIaX$*$N8dSVp7+{mT>N}mKosSF;%>Z?xysVJdffe^Q6F?aXM^Rm3
    zO`pq2bElDtf1B=VdE$<|sqs{Hp=bV@P3W~LgV|TFLfiw3A
    z=3q+Pq`R|O0NzNW%;*Vy>jGmua@p{RrK)TrR_BwT*FbA=li38dUqMV(!@CnU-NqwM
    z1h;hCQLR2J!!|%8kj64m)!`4--h{62P9*oS0{{UQH;!fSPDrpATm61hqMl_wt;^pEXB7hGbL|C=$#2%H(c^778H|QYfMG!Z~i-NA3
    zN%m9_6lT&$CTumC$+B}`LXi#IMwX^)gsp?yBcPKA^ZTG*gLVd{BIj*c)nGpa_?6-H
    z(kqF|?uIh#`SK8R+D8lY8HC&s#DvM>OD<%}qpIQ9l~r;qC{ATMqkK73-XISF89G7v
    zdv()R*$A-j19tHWd_SH~E`W?>XmyCFSd4Ix!~8nNe408I*4vQW4p~2F2s8qE9?8yu
    z?nG`8(Bq&}pm#wd&>saI2Au#s2buwW5T%HMeh0c8vArMkAy5Pq2kinGAR9CW`U+?o
    z^i9z7AiRfWSWkFU8~mef0<8y0J~gpB&_na(VjzCUZ+LR_TFp*PtHt;=)oKDRW-hj^!e6Yg7^FU@
    z3ZV+WzU>W2|3C0W7B}fP)U%yF()o>g{#k)@F5k*w+{sli6)VSi5$4=ruBXsnqmQI?+JsU;8)-P3j8v4Q6&Hpz*Emh9L
    ZX_39Lim^}olNO3?kG%H-;;c&w{{g2@-Q)lO
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-commdlg-debug.app b/src/test/samples/golden/inc-commdlg-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..c19110893ea3bc0613948fc47a5e16a411cc52fc
    GIT binary patch
    literal 6487
    zcmds6ZERat89rGv7PX0-N`+3KuD64fQXnYZRDLYg^5L7PY+i0`VyCoO=Gxb>Q~O?X
    z?{!F_%ThTmaoj6q+BzzJZ4{xP4kQMHAvW-o5~E2_@maA60zW2h)SwJ)e6$S8bI!Tf
    zaU9omgTfDv^7@?jJ@5OxALpF=75AXnHcJS(fwl*|1gZn=-bTn4n{Pu;+oijBzuO)k
    zg+d2p_k*ZCZGU(VA;ZCB(k<&DEtWJ?jZ8wFihR+O5mrB^kZ0k53OvD>!Vy=0=dU)+
    zmbT|8cqVVS2C{Ev#fG7!H{iq^BW?M4a#%^G3}Sv~FEuw`{xI#JmR@dPR=I)ZOn%`G
    zVtlZ_Kj+!e?~Q&G3b}zsZ}ejrNY*T%(wBtn^3eQjcDXG-G#{B=`U1y)L={46A{mRz
    zVIiT0Q*l{{s3|2Z#1z4Z#&iMk$77+O5mS{`B5E2ERe}kbw6yNmNmPy}Bd{ec`Y|T(
    z^tlHf776bJP1edlR`+75rHy-kYu5&(|5RA&{B#KOUSKoO}%&bJ<@ZymyX<>pY9~HRqP|GmRL3K
    z>GOJ8;$hMyk09k9I!FDZz5&1Q^zs}nRb^e={vpJhdqJ){eYwC%5
    zLJFC2a8M>D8~LT&D|9~kGDHEZ$+lU*5+6!0H|8Fy@3;Q(F9rnBJydj#BBb;6esA#yF)^C`GfLXuw8rLplPT7tmlMSUD3naP#8_D`A*UC~y_x1N93aTD3(~a1Sl9}Ft
    zETWg88m*@S%V7FD{MHE@UYx`vG{wo>IhueJy#n5AJ+un!n)*6UL?Dn8Cndji02A3l
    zHWP+&_Wlo9_j1L|4cF8~s1?L13TTQ`UMXj?lI(=%e(U!^NIj`UP8Z&4#e>na(6p%!zXTLSerQkal
    zKUw6P8J`62GtU@*MO8k9*zD3AVQPXe>TzTNON1=Ty!oO5h)*w^`nzr5xArlE9RX`M
    z)k6F`(HF$0;RIzk%R71zB1cT|>`@*yD)`nvX(bwSuh3lBkq)2;uQMsOS=7#3LEeRo
    z0gghLsX5cwOLFI^z!cAWqgli=+ALYmaX3?)E{M}qm6sh$dS2?c(l%^?mh$JAl~DWX
    z4k=*8?3sBwvmeeNHy?lg`z|Og)NjG1=>|zywr0Tk1oxONjBblQ
    zi6|s%7ssMV^U$BUt9`4{Je4;ZXL{>;(bu>!b|fV08R&l|_acQlGK!sW
    z20UGu3A-OdZ?A*jih&vCdr-2Dfx#g+<#*chM;{#?ng?pSuq~U311n%1q5uUb%%Hkd
    zG`E-%mM$R``!e0$@WkEeYY!U2bbVXrOucJv(P(JjI$^aprL4NYEYuO?y5xeP>G{*u
    zw?+wD_Nag)qTdCIWW8~Xg~m>V^)oOCjh#p9RWLlXm!M8qbPfPrZyVstTwDS!Zr8T%8ssT-Plk~{^YA5*U|rLf{S
    z&=hJ!uESsro}|STqlg!Gro&_7%u|;-PT~xRJMpl4EgU(wK=K;=vk;Q;ph53Zo)h7i
    z=9NP!O^=Pqc*2R)8=F-EYx=5zxsJP`7+MHBy?@E+=9D3?#!rLneEi
    zGU#}Gi1*3{-6iz^TN{}-rTAirBpxm%5CMF!AUv|J2S;RD&OKO}i3y9vwwzlP$7IFOi@W5kQ=H;*`q*|TK0%%aGIX5s
    zcgIy-CJ|si0PNfvd^?_xmq7Xxba(Kmh@avhht++I^;F~7SZ`xyJLY;pL!b!g1thx!
    zx(B&=L63tjg5Cv1pg#sW13C|S9yAYnKT6>P{T8$xvAqxU0gx9I038Dd4&~q1p4C;lGoc-}0N*c(75n=g;eFf17%7jEY0{L^qn^*`}=!|#@ZoX)5srp67m&W
    b%)hs<)hXquei|=bc%@^_?H}}hv4sB*==ryD
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-commdlg-release.app b/src/test/samples/golden/inc-commdlg-release.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..478eabc6714ddf730e7f72165fe8ec1c9901a21e
    GIT binary patch
    literal 6423
    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
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-help-debug.app b/src/test/samples/golden/inc-help-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..c67de4500df5960cdbe1e96875304313eb2190f5
    GIT binary patch
    literal 6394
    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
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-resource-debug.app b/src/test/samples/golden/inc-resource-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..d98eb979f1c92b4a5d40e906f749d31cf60d2c07
    GIT binary patch
    literal 6499
    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
    CkFqZS
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-sql-debug.app b/src/test/samples/golden/inc-sql-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..7310685060122f2deb65dff8789a6a659ee183ef
    GIT binary patch
    literal 6542
    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
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-sql-release.app b/src/test/samples/golden/inc-sql-release.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..c47205857a9ae9959c3601186ddfe6c0db3505d4
    GIT binary patch
    literal 6487
    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
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/notepad-debug.app b/src/test/samples/golden/notepad-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..945a3ce818af8b3ba05b908b7400cfc187ab3975
    GIT binary patch
    literal 10065
    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
    H!%F`KJuYhL
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/notepad-release.app b/src/test/samples/golden/notepad-release.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..64c91d62a0b3c3e1d79462cc8a5aea5427be1975
    GIT binary patch
    literal 9021
    zcmeHNe{37o9e;M5Hgyw{K%w1G+iSLFv|F<}X;To*Na{FU3u!NQZMSPnxZ0Q4t#jwu
    z=a8~e5|UJPU8_OR4UK>3n5JxnNnaa{ZJ>CFOt&2>YWOE7zT*ZE=3gsG5{ek1KE+dKI>jAVP6YiyitQQ|3$WuK^}>DgyKM;E9KUMS_KuIFFSy3`j(3ere$Bw&rV1go
    zCmxN-VWCG2r(&|urKXgy5LE;%5={#5KNby1T2xi)m?x1ypo-KZvz2wLlPn^~;$4`_
    zRwnmwM*H?w|7{*-T!e(2R8xtNEGVjGz}B%giQCdtAuK25L{y4J?~+Z^b*$C1$-kK)
    z?(Hc}P`iX4xkpVL6ha9Z9w^;#NftJ>__nx(L`u=3J#rmPlXds@ZEagNQ?IpUM++PI
    zi1}S=q9;GSZM(O9Wh~4#$=wLKjbvmW`3(kq0~baqm3zxFC6CYoW!+_>-tr$xXH|MQ
    zjuZ}$RSwgdq_^&tlWYZn!fF^bv2MTjDD7OD{kF?5aVFAC@fOn6T}gwXW#XHHT8V&oN!z514+f2H*NOWuiRuEXBgNbP!2+
    zjdM|E5i@QDaZe0u7!gD^HJa6GS>_}$vYrRMku?0-UnT0N47jZ4=!EAesTyg=l0GPQ
    z=z}Kg1f}w2NJZrSXtNm9qo!n>B&%Tz{OsM2{9oIg;JO>l)jAd*|M9pbkzf
    zOb^F^71WyuU;+xqkX>Spo=XXn&m$D`HodXzo+X3t-J%Ic9j=yRj>^$YDVSQso
    zO1Hgz#>NoqWF;nM+y8q0t^JHIdqhAGkskp?)L(y(yT*5f{woaN8sCrla~K$|Jq>ln
    zy|Vyly-k2`8fxcXLEZn+VskU1ei*Wy7T#A)#qUG$Jt&_4UABF6aut~0Q@+OBBLjCX~ASXa#?3rLY7%Qs`Eb3YoL|5
    z$uvUkHxSdT&V5pnZsT6fi(7h9lM`);9<~}9mNd}glQsTObxX0>J}Gp79Rdij_~bwj
    z9_m^`J;?6Iq+gI#OeI)PAJ7z<#NNk*B0Mn>RkR#l%o2xt`?#e|Y9EVnAj3#If7Zf~
    z^$27y!ajE)9+NbBMztRZM-yH-lu9I{y)yd)Am6ZHb1~kgO5v7Ri~>%w-l;+69i}Lg
    ztQ=1&%WES?R;WiXm0O3yz5~&CFv(tm^3TD>AL|6$D<{3O6lQM%VJCEdKn}y9g9V_i
    z^1f7eVN^>kj{)G1hb2vBE37mm@%16LS=OjZ8~`i!%$rht(VjTITpWf6u)&>Zmy=1U
    zTc&ibz{>0c9R@uO;?wa(Ar@0Z?0(BBtfUf>l5(gg&dz}eMb?>A;JI#&AKF*PZ(E@0mb>`W29>Cc)1$UqNOhv5|q5DsF<*D=;p
    zjiIpKfutI;&7i%YF3{r$b`rDH~cPGzxkQ^f+jns>yNGmoKDhn$Vh(l_b9Mv0EjjTS};^Af}X1
    zMDS^;a8yk)w;Df~h;~Obp?+pHS?2$VNXD
    zrHAZHtf99I7?dtx$4i}T-o3UmYqXHr91LT>xQmo9z1p5rEoc44sH+-aHYQbzg&b6zvilN@F`wH*gg-OIALQg7T050Wb6bt
    z-Qum_y$U8kN1Vq>uOf&OcII`m(n`W20r_5qNUDi3;1{dR@F-Rvo#Oe$CB(wKtw4ld
    zE4kC%RGMsyC78~CJNxtt`{;aYulZseAf5Du2)a3!(a5j7f;Tk)Pr?dH?B
    zD8|`e1xdvOUVdqWMh7i!S0n09#xY)8OC{tT(!S(E8lw}8hP8-;?ZAP8lL>1C^RVJ-
    z=ErL&jozN+cy_j0umfv8BOJUpn{OR$%x7dtXi3&^Ac4Y43OB5-)B3bkhj5IS=d51k
    zU=%T&N@)&$+EL0x3CEcW;89EtX{3(2vYH*6$nEN+6CW~;O`mHvo1L|}h
    Lx;FH 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 Gum1%N-u5*B literal 0 HcmV?d00001 diff --git a/src/test/samples/golden/widshow-debug.app b/src/test/samples/golden/widshow-debug.app new file mode 100644 index 0000000000000000000000000000000000000000..de20fc3cb294c8fbd0201e9ae5d15aa2bfaecf0b GIT binary patch literal 13890 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 literal 0 HcmV?d00001 diff --git a/src/test/samples/runSamples.sh b/src/test/samples/runSamples.sh new file mode 100755 index 0000000..e6faec0 --- /dev/null +++ b/src/test/samples/runSamples.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# 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. + +# runSamples.sh -- BASIC sample pipeline regression test +# +# Compiles every sample project (src/apps/kpunch/**/*.dbp) and every BASIC +# include file (src/include/basic/*.bas, wrapped in a generated +# DECLARE-only project) with bin/host/bascomp, in both debug and release +# mode, and compares each .app byte-for-byte against the golden copy in +# golden/. bascomp output is deterministic (no timestamps, seeds or +# absolute paths are embedded), so a byte compare is meaningful. +# +# Usage: +# runSamples.sh run the comparison, non-zero exit on failure +# UPDATE_GOLDENS=1 runSamples.sh regenerate golden/ from current output +# +# Optional environment: +# BASCOMP compiler binary (default: bin/host/bascomp) +# SAMPLES_OUT scratch directory (default: obj/test/samples) + +set -u + +scriptDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repoRoot="$(cd "$scriptDir/../../.." && pwd)" +goldenDir="$scriptDir/golden" +bascomp="${BASCOMP:-$repoRoot/bin/host/bascomp}" +outDir="${SAMPLES_OUT:-$repoRoot/obj/test/samples}" +appDir="$repoRoot/src/apps/kpunch" +incDir="$repoRoot/src/include/basic" +update="${UPDATE_GOLDENS:-0}" + +passCount=0 +failCount=0 + + +# compileOne